build/DFDocExamples.ps1
|
#Requires -Version 7.0 # Shared logic for the documentation checks (tests/Docs.Examples.Tests.ps1 and # tests/Docs.Links.Tests.ps1). Author-time only: the module never loads this file. # # Code-block modes. The HTML comment on the line before a ```powershell fence # sets how the block is checked; every block must parse regardless. # (none) run it in a sandbox; it must exit 0 with nothing on stderr # <!-- fragment --> a deliberately partial snippet: parse only # <!-- interactive --> needs a person at the keyboard (fzf, prompts): parse only # <!-- network --> reads from the network (catalog lookups, downloads into # the sandbox): parse only, run as well when # $Env:DF_DOCS_NETWORK = '1'. Never use it for installs. # <!-- system --> changes the machine outside the sandbox (installs, # elevation, scheduled tasks, global git config, # clipboard, coreutils-manager): parse only, never run # # Expected output. A ```text fence right after a run block is compared with the # block's stdout (ANSI stripped, trailing whitespace ignored). A line that is # exactly '...' matches any number of lines; '...' inside a line matches any text # on that line (machine-specific paths, versions). Put <!-- output: varies --> # before the ```text fence to show illustrative output that is not compared. $script:DFDocFence = '^(\s*)```(\w*)\s*$' function Get-DFDocCodeBlock { <# .SYNOPSIS Returns every fenced code block in a Markdown file, with its mode and any expected output. .PARAMETER Path The Markdown file. .OUTPUTS PSCustomObject with File, Line, Lang, Code, Mode, Expected (string or $null), OutputVaries. #> param([Parameter(Mandatory)][string]$Path) $lines = (Get-Content $Path -Raw) -replace '\r', '' -split '\n' $blocks = [System.Collections.Generic.List[object]]::new() $i = 0 while ($i -lt $lines.Count) { if ($lines[$i] -match $script:DFDocFence) { $lang = $Matches[2] $start = $i $body = [System.Collections.Generic.List[string]]::new() $i++ while ($i -lt $lines.Count -and $lines[$i] -notmatch '^\s*```\s*$') { $body.Add($lines[$i]); $i++ } # The marker is the nearest non-blank line above the fence. $j = $start - 1 while ($j -ge 0 -and -not $lines[$j].Trim()) { $j-- } $marker = if ($j -ge 0 -and $lines[$j] -match '^\s*<!--\s*(fragment|interactive|network|system)\s*-->\s*$') { $Matches[1] } else { $null } $blocks.Add([pscustomobject]@{ File = $Path; Line = $start + 1; Lang = $lang; Code = ($body -join "`n") Mode = $marker ?? 'run'; Expected = $null; OutputVaries = $false; EndLine = $i }) } $i++ } # Pair each powershell block with a ```text block that directly follows it. for ($b = 0; $b -lt $blocks.Count - 1; $b++) { $cur = $blocks[$b]; $next = $blocks[$b + 1] if ($cur.Lang -ne 'powershell' -or $next.Lang -ne 'text') { continue } # Lines strictly between this block's closing fence (index EndLine) and the # next opening fence (index Line - 1). Guard the empty case: PowerShell's # a..b counts down when b < a. $from = $cur.EndLine + 1; $to = $next.Line - 2 # @() around the whole if: assigning an if-expression unwraps a one-element # array to a bare string, and then [0] would be its first character. $between = @(if ($to -ge $from) { $lines[$from..$to] | Where-Object { $_.Trim() } }) $varies = $false if ($between.Count -eq 1 -and $between[0] -match '^\s*<!--\s*output:\s*varies\s*-->\s*$') { $varies = $true } elseif ($between.Count -gt 0) { continue } $cur.Expected = $next.Code $cur.OutputVaries = $varies } $blocks | Where-Object Lang -EQ 'powershell' } function New-DFDocModuleCopy { <# .SYNOPSIS Copies the module's runtime files into <Destination>\DotForge and returns <Destination>. .DESCRIPTION A copy rather than a link to the repo, so cleaning up a sandbox can never reach the working tree. Only what the module loads is copied. .PARAMETER RepoRoot The repository root. .PARAMETER Destination Folder to put the DotForge module folder in; add it to PSModulePath. .OUTPUTS System.String. #> param([Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][string]$Destination) $target = Join-Path $Destination 'DotForge' New-Item -ItemType Directory -Path $target -Force | Out-Null foreach ($item in 'DotForge.psd1', 'DotForge.psm1', 'Shared', 'Public', 'Private', 'Bundle', 'Modules', 'Tools', 'data') { Copy-Item -Path (Join-Path $RepoRoot $item) -Destination $target -Recurse -Force } $Destination } function Get-DFDocToolVariable { <# .SYNOPSIS Returns every environment variable name a tool record manages (its xdg.vars and env keys). .DESCRIPTION The sandbox removes these so a value persisted in your user environment (e.g. MDV_CONFIG_PATH pointing at your real config folder) can't lead an example to read or write outside the throwaway home. .PARAMETER RepoRoot The repository root. .OUTPUTS System.String[]. #> param([Parameter(Mandatory)][string]$RepoRoot) $names = foreach ($f in Get-ChildItem (Join-Path $RepoRoot 'Tools') -Filter '*.json') { $t = Get-Content $f.FullName -Raw | ConvertFrom-Json $xdg = $t.PSObject.Properties['xdg']?.Value $vars = if ($xdg) { $xdg.PSObject.Properties['vars']?.Value } if ($vars) { $vars.PSObject.Properties.Name } $env = $t.PSObject.Properties['env']?.Value if ($env) { $env.PSObject.Properties.Name } } @($names | Sort-Object -Unique) } function Invoke-DFDocExample { <# .SYNOPSIS Runs one code block in a fresh pwsh with a throwaway home directory. .DESCRIPTION The child gets: USERPROFILE/HOME set to a new empty folder (so $HOME and every XDG default land there), no XDG_* variables and none of the -RemoveVariable names (pass Get-DFDocToolVariable), GIT_CONFIG_GLOBAL pointing at an empty file in that folder (so nothing can touch your real git config), NO_COLOR=1, no Pager, an already-closed stdin, and the module copy first on PSModulePath. The working directory is the sandbox's 'work' folder. .PARAMETER Code The PowerShell source to run. .PARAMETER ModulesRoot Folder holding the DotForge module copy (New-DFDocModuleCopy). .PARAMETER SandboxRoot Parent folder for this run's sandbox. .PARAMETER RemoveVariable More environment variable names to remove from the child. .PARAMETER TimeoutSeconds Kill the child after this long. Default: 120. .OUTPUTS PSCustomObject with ExitCode, StdOut, StdErr, TimedOut. #> param( [Parameter(Mandatory)][string]$Code, [Parameter(Mandatory)][string]$ModulesRoot, [Parameter(Mandatory)][string]$SandboxRoot, [string[]]$RemoveVariable = @(), [int]$TimeoutSeconds = 120 ) $box = Join-Path $SandboxRoot ([guid]::NewGuid().ToString('N').Substring(0, 8)) $home_ = Join-Path $box 'home' $work = Join-Path $box 'work' New-Item -ItemType Directory -Path $home_, $work -Force | Out-Null $gitConfig = Join-Path $home_ '.gitconfig' New-Item -ItemType File -Path $gitConfig -Force | Out-Null $script = Join-Path $box 'example.ps1' # The child writes in its console's code page (437 on many Windows # terminals, which turns an em dash into '-') while we read UTF-8: make it # write UTF-8. Prefixed to the first line so line numbers don't shift. $prefix = '[Console]::OutputEncoding = [Text.Encoding]::UTF8; ' Set-Content -Path $script -Value ($prefix + $Code) -Encoding utf8 $psi = [System.Diagnostics.ProcessStartInfo]::new('pwsh') foreach ($a in '-NoProfile', '-NonInteractive', '-File', $script) { $psi.ArgumentList.Add($a) } $psi.WorkingDirectory = $work $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true # A closed stdin, so nothing in an example can block reading the test run's # own stdin (an inherited pipe that never closes made glow hang). $psi.RedirectStandardInput = $true $psi.UseShellExecute = $false $psi.StandardOutputEncoding = [Text.Encoding]::UTF8 $psi.StandardErrorEncoding = [Text.Encoding]::UTF8 foreach ($k in @($psi.Environment.Keys)) { if ($k -like 'XDG_*' -or $k -in 'Pager', 'PAGER', 'DFConfig' -or $k -in $RemoveVariable) { $psi.Environment.Remove($k) | Out-Null } } $psi.Environment['USERPROFILE'] = $home_ $psi.Environment['HOME'] = $home_ $psi.Environment['GIT_CONFIG_GLOBAL'] = $gitConfig $psi.Environment['NO_COLOR'] = '1' $psi.Environment['PSModulePath'] = $ModulesRoot + [IO.Path]::PathSeparator + $psi.Environment['PSModulePath'] $proc = [System.Diagnostics.Process]::Start($psi) $proc.StandardInput.Close() $out = $proc.StandardOutput.ReadToEndAsync() $err = $proc.StandardError.ReadToEndAsync() $timedOut = -not $proc.WaitForExit($TimeoutSeconds * 1000) if ($timedOut) { try { $proc.Kill($true) } catch { } ; $proc.WaitForExit() } [pscustomobject]@{ ExitCode = $proc.ExitCode StdOut = $out.Result StdErr = $err.Result TimedOut = $timedOut } } function Test-DFDocLine([string]$Pattern, [string]$Line) { # Exact, case-sensitive match, except that '...' matches any run of text. if ($Pattern -notlike '*...*') { return $Pattern -ceq $Line } $rx = '^' + (($Pattern -split '\.\.\.' | ForEach-Object { [regex]::Escape($_) }) -join '.*') + '$' $Line -cmatch $rx } function Compare-DFDocOutput { <# .SYNOPSIS Compares actual output with a doc's expected output; returns $null on a match, else a message. .PARAMETER Expected The expected text. A line that is exactly '...' matches any number of lines; '...' inside a line matches any text on that line. .PARAMETER Actual The captured stdout. .OUTPUTS System.String or $null. #> param([string]$Expected, [string]$Actual) $norm = { param($t) @((($t -replace '\x1b\[[0-9;?]*[A-Za-z]', '') -replace '\r', '').TrimEnd() -split '\n' | ForEach-Object TrimEnd) } $exp = & $norm $Expected $act = & $norm $Actual # Leading blank lines in actual output are noise from the host. while ($act.Count -and -not $act[0]) { $act = @($act | Select-Object -Skip 1) } # Glob-style match with '...' as a multi-line wildcard (memoized recursion). $memo = @{} $match = $null $match = { param([int]$e, [int]$a) $key = "$e,$a" if ($memo.ContainsKey($key)) { return $memo[$key] } $r = if ($e -eq $exp.Count) { $a -eq $act.Count } elseif ($exp[$e] -eq '...') { (& $match ($e + 1) $a) -or ($a -lt $act.Count -and (& $match $e ($a + 1))) } else { $a -lt $act.Count -and (Test-DFDocLine $exp[$e] $act[$a]) -and (& $match ($e + 1) ($a + 1)) } $memo[$key] = $r $r } if (& $match 0 0) { return $null } "Output differs.`n--- expected ---`n$($exp -join "`n")`n--- actual ---`n$($act -join "`n")" } function Get-DFDocHeadingSlug { <# .SYNOPSIS Returns the GitHub anchor slugs for every heading in a Markdown file. .PARAMETER Path The Markdown file. .OUTPUTS System.String[]. #> param([Parameter(Mandatory)][string]$Path) $seen = @{} $inFence = $false foreach ($line in ((Get-Content $Path -Raw) -replace '\r', '' -split '\n')) { if ($line -match '^\s*```') { $inFence = -not $inFence; continue } if ($inFence -or $line -notmatch '^#{1,6}\s+(.+?)\s*#*\s*$') { continue } $text = $Matches[1] -replace '`', '' -replace '\[([^\]]*)\]\([^)]*\)', '$1' $slug = ($text.ToLowerInvariant() -replace '[^\p{L}\p{Nd} _-]', '') -replace ' ', '-' if ($seen.ContainsKey($slug)) { $seen[$slug]++; "$slug-$($seen[$slug])" } else { $seen[$slug] = 0; $slug } } # Explicit anchors: <a id="..."></a> foreach ($m in [regex]::Matches((Get-Content $Path -Raw), '<a\s+(?:id|name)="([^"]+)"')) { $m.Groups[1].Value } } function Get-DFDocLink { <# .SYNOPSIS Returns every relative link and image in a Markdown file (outside code), resolved against the file. .PARAMETER Path The Markdown file. .OUTPUTS PSCustomObject with File, Line, Target, TargetPath (or $null for same-page), Anchor. #> param([Parameter(Mandatory)][string]$Path) $dir = Split-Path $Path -Parent $lines = (Get-Content $Path -Raw) -replace '\r', '' -split '\n' $inFence = $false for ($n = 0; $n -lt $lines.Count; $n++) { $line = $lines[$n] if ($line -match '^\s*```') { $inFence = -not $inFence; continue } if ($inFence) { continue } $line = $line -replace '`[^`]*`', '' # links inside code spans aren't links $targets = @( [regex]::Matches($line, '\]\(([^)\s]+)(?:\s+"[^"]*")?\)') | ForEach-Object { $_.Groups[1].Value } [regex]::Matches($line, '<img\s[^>]*src="([^"]+)"') | ForEach-Object { $_.Groups[1].Value } ) foreach ($t in $targets) { if ($t -match '^(https?:|mailto:)') { continue } $file, $anchor = $t -split '#', 2 [pscustomobject]@{ File = $Path Line = $n + 1 Target = $t TargetPath = if ($file) { [IO.Path]::GetFullPath((Join-Path $dir ([uri]::UnescapeDataString($file)))) } else { $null } Anchor = $anchor } } } } |