name: Validate Documentation on: pull_request: paths: - 'src/slic3r/GUI/Tab.cpp' - 'doc/**/*.md' workflow_dispatch: permissions: contents: read pull-requests: write issues: write jobs: validate: runs-on: windows-latest name: Check Documentation steps: - name: Checkout uses: actions/checkout@v5 - name: Get changed files id: changed-files uses: tj-actions/changed-files@v47 with: files: | src/slic3r/GUI/Tab.cpp doc/**/*.md - name: Run validation if: steps.changed-files.outputs.any_changed == 'true' shell: pwsh run: | # Helper Functions function Normalize-Fragment($fragment) { return $fragment.ToLower().Trim() -replace '[^a-z0-9\s-]', '' -replace ' ', '-' -replace '^-+|-+$', '' } function Add-BrokenReference($sourceFile, $line, $target, $issue, $type) { return @{ SourceFile = $sourceFile Line = $line Target = $target Issue = $issue Type = $type } } function Validate-Fragment($fragment, $availableAnchors, $sourceFile, $line, $target, $type) { $cleanFragment = $fragment.StartsWith('#') ? $fragment.Substring(1) : $fragment $normalizedFragment = Normalize-Fragment $cleanFragment if ($availableAnchors -notcontains $normalizedFragment) { return Add-BrokenReference $sourceFile $line $target "Fragment does not exist" $type } return $null } function Get-ImagesFromLine($line) { $images = @() $lineForParsing = [regex]::Replace($line, '`[^`]*`', '') # Process markdown and HTML images $imagePatterns = @( @{ Pattern = "!\[([^\]]*)\]\(([^)]+)\)"; Type = "Markdown"; AltGroup = 1; UrlGroup = 2 } @{ Pattern = ']*>'; Type = "HTML"; AltGroup = -1; UrlGroup = -1 } ) foreach ($pattern in $imagePatterns) { foreach ($match in [regex]::Matches($lineForParsing, $pattern.Pattern)) { $altText = "" $url = "" if ($pattern.Type -eq "Markdown") { $altText = $match.Groups[$pattern.AltGroup].Value $url = $match.Groups[$pattern.UrlGroup].Value } else { # Extract from HTML $imgTag = $match.Value if ($imgTag -match 'alt\s*=\s*[`"'']([^`"'']*)[`"'']') { $altText = $matches[1] } if ($imgTag -match 'src\s*=\s*[`"'']([^`"'']*)[`"'']') { $url = $matches[1] } } $images += @{ Match = $match.Value Type = $pattern.Type AltText = $altText Url = $url StartIndex = $match.Index Length = $match.Length } } } return $images } # Initialize $tabFile = Join-Path $PWD "src/slic3r/GUI/Tab.cpp" $docDir = Join-Path $PWD 'doc' $brokenReferences = @() $docIndex = @{} Write-Host "Validating documentation..." -ForegroundColor Blue # Validate paths $hasTabFile = Test-Path $tabFile if (-not $hasTabFile) { Write-Host "::warning::Tab.cpp file not found at: $tabFile" } if (-not (Test-Path $docDir)) { Write-Host "::error::doc folder does not exist"; exit 1 } # Build documentation index $mdFiles = Get-ChildItem -Path $docDir -Filter *.md -Recurse -File -ErrorAction SilentlyContinue foreach ($mdFile in $mdFiles) { $baseName = [System.IO.Path]::GetFileNameWithoutExtension($mdFile.Name) $relPath = (Resolve-Path $mdFile.FullName).Path.Substring($docDir.Length).TrimStart('\', '/') $content = Get-Content -Path $mdFile.FullName -Encoding UTF8 -Raw $lines = Get-Content -Path $mdFile.FullName -Encoding UTF8 # Extract anchors $anchors = @() $anchors += [regex]::Matches($content, '(?i)]*(?:name|id)\s*=\s*[`"'']([^`"'']+)[`"'']') | ForEach-Object { $_.Groups[1].Value.ToLower() } $anchors += [regex]::Matches($content, '(?m)^#+\s+(.+)$') | ForEach-Object { Normalize-Fragment $_.Groups[1].Value.Trim() } # Parse links $links = @() $inCodeFence = $false for ($i = 0; $i -lt $lines.Count; $i++) { $line = $lines[$i] if ($line.TrimStart() -match '^(```|~~~)') { $inCodeFence = -not $inCodeFence continue } if ($inCodeFence) { continue } $lineForParsing = [regex]::Replace($line, '`[^`]*`', '') # Get all images from this line to skip them in link processing $imagesInLine = Get-ImagesFromLine $line $imageRanges = @() foreach ($img in $imagesInLine) { # Exclude the entire image syntax from link processing $imageRanges += @{ Start = $img.StartIndex; End = $img.StartIndex + $img.Length } } # Find all markdown links, but exclude those that are part of images foreach ($linkMatch in [regex]::Matches($lineForParsing, '(?append_single_option_line\s*\(\s*(?:"([^"]+)"|([^,]+?))\s*,\s*"([^"]+)"\s*\)' $lines = Get-Content -Path $tabFile -Encoding UTF8 for ($i = 0; $i -lt $lines.Count; $i++) { foreach ($match in [regex]::Matches($lines[$i], $regex)) { $arg2Full = $match.Groups[3].Value.Trim() if ($arg2Full.Contains('##')) { $brokenReferences += Add-BrokenReference "Tab.cpp" ($i + 1) $arg2Full "Use single # for fragments." "Link" continue } $arg2Parts = $arg2Full -split '#', 2 $docBase = $arg2Parts[0].Trim() $fragment = ($arg2Parts.Length -gt 1) ? $arg2Parts[1].Trim() : $null if (-not $docIndex.ContainsKey($docBase)) { $brokenReferences += Add-BrokenReference "Tab.cpp" ($i + 1) $docBase "File does not exist" "Link" } elseif ($fragment) { $validationResult = Validate-Fragment $fragment $docIndex[$docBase].Anchors "Tab.cpp" ($i + 1) "$docBase#$fragment" "Link" if ($validationResult) { $brokenReferences += $validationResult } } } } } # Validate markdown links foreach ($baseName in $docIndex.Keys) { foreach ($link in $docIndex[$baseName].Links) { if (-not $docIndex.ContainsKey($link.TargetBase)) { $brokenReferences += Add-BrokenReference $link.SourceFile $link.Line "$($link.TargetBase).md" "File does not exist" "Link" } elseif ($link.Fragment) { $validationResult = Validate-Fragment $link.Fragment $docIndex[$link.TargetBase].Anchors $link.SourceFile $link.Line "$($link.TargetBase)#$($link.Fragment)" "Link" if ($validationResult) { $brokenReferences += $validationResult } } } } # Validate images Write-Host "Validating images..." -ForegroundColor Blue $expectedUrlPattern = '^https://github\.com/SoftFever/OrcaSlicer/blob/main/([^?]+)\?raw=true$' foreach ($file in $mdFiles) { $lines = Get-Content $file.FullName -Encoding UTF8 $relPath = (Resolve-Path $file.FullName).Path.Substring($docDir.Length).TrimStart('\', '/') $inCodeFence = $false for ($lineNumber = 0; $lineNumber -lt $lines.Count; $lineNumber++) { $line = $lines[$lineNumber] if ($line.TrimStart() -match '^(```|~~~)') { $inCodeFence = -not $inCodeFence continue } if ($inCodeFence) { continue } # Use the unified image detection function $imagesInLine = Get-ImagesFromLine $line foreach ($image in $imagesInLine) { $altText = $image.AltText $url = $image.Url $imageMatch = $image.Match $imageType = $image.Type if (-not $altText.Trim() -and $url) { $brokenReferences += Add-BrokenReference $relPath ($lineNumber + 1) $imageMatch "[$imageType] Missing alt text for image" "Image" } elseif ($url -and $altText) { # Validate URL format and file existence if ($url -match $expectedUrlPattern) { $relativePathInUrl = $matches[1] $fileNameFromUrl = [System.IO.Path]::GetFileNameWithoutExtension($relativePathInUrl) if ($altText -ne $fileNameFromUrl) { $brokenReferences += Add-BrokenReference $relPath ($lineNumber + 1) $imageMatch "[$imageType] Alt text `"$altText`" ≠ filename `"$fileNameFromUrl`"" "Image" } $expectedImagePath = Join-Path $PWD ($relativePathInUrl -replace "/", "\") if (-not (Test-Path $expectedImagePath)) { $brokenReferences += Add-BrokenReference $relPath ($lineNumber + 1) $imageMatch "[$imageType] Image not found at path: $relativePathInUrl" "Image" } } else { $urlIssues = @() if (-not $url.StartsWith('https://github.com/SoftFever/OrcaSlicer/blob/main/')) { $urlIssues += "URL must start with expected prefix" } if (-not $url.EndsWith('?raw=true')) { $urlIssues += "URL must end with '?raw=true'" } if ($url -match '^https?://(?!github\.com/SoftFever/OrcaSlicer)') { $urlIssues += "External URLs not allowed" } $issueText = "[$imageType] URL format issues: " + ($urlIssues -join '; ') $brokenReferences += Add-BrokenReference $relPath ($lineNumber + 1) $imageMatch $issueText "Image" } } } } } # Report results $linkErrors = $brokenReferences | Where-Object { $_.Type -eq "Link" } $imageErrors = $brokenReferences | Where-Object { $_.Type -eq "Image" } if ($brokenReferences.Count -gt 0) { Write-Host "::error::Documentation validation failed" # Build error summary for PR comment $errorSummary = "" # Report link errors if ($linkErrors) { Write-Host "::group::🔗 Link Validation Errors" $errorSummary += "## 🔗 Link Validation Errors`n`n" $linkErrors | Group-Object SourceFile | ForEach-Object { Write-Host "📄 $($_.Name):" -ForegroundColor Yellow $errorSummary += "**📄 doc/$($_.Name):**`n" $_.Group | Sort-Object Line | ForEach-Object { Write-Host " Line $($_.Line): $($_.Target) - $($_.Issue)" -ForegroundColor Red Write-Host "::error file=doc/$($_.SourceFile),line=$($_.Line)::$($_.Target) - $($_.Issue)" $errorSummary += "- Line $($_.Line): ``$($_.Target)`` - $($_.Issue)`n" } $errorSummary += "`n" } Write-Host "::endgroup::" } # Report image errors if ($imageErrors) { Write-Host "::group::🖼️ Image Validation Errors" $errorSummary += "## 🖼️ Image Validation Errors`n`n" $imageErrors | Group-Object SourceFile | ForEach-Object { Write-Host "📄 $($_.Name):" -ForegroundColor Yellow $errorSummary += "**📄 doc/$($_.Name):**`n" $_.Group | Sort-Object Line | ForEach-Object { Write-Host " Line $($_.Line): $($_.Issue)" -ForegroundColor Red Write-Host "::error file=doc/$($_.SourceFile),line=$($_.Line)::$($_.Issue)" $errorSummary += "- Line $($_.Line): $($_.Issue)`n" } $errorSummary += "`n" } Write-Host "::endgroup::" } # Export error summary for PR comment Add-Content -Path $env:GITHUB_ENV -Value "VALIDATION_ERRORS<