Files
winutil/tools/devdocs-generator.ps1
Sean (ANGRYxScotsman)andGitHub 6f0629207a Gave the docs steroids. sorry for the big pr. (#4892)
* Scaffold Astro + Starlight docs site

Bootstraps a new docs-astro project to replace the Hugo-based docs,
using Astro's Starlight framework with the content collection schema
and sidebar navigation configured for WinUtil's docs structure.

* Add WinUtil-branded Starlight theme

Restyles Starlight's default look with a dark-by-default grayscale
palette and WinUtil's brand blue (#0567ff, from the app logo) as the
single accent, Geist for UI text, and JetBrains Mono for code. Also
overrides the default theme provider so first-time visitors land on
dark mode instead of following OS preference.

* Add custom Hero and CornerCard components

Hero overrides Starlight's default hero with a full-bleed grid/glow
background, a browser-chrome-framed screenshot, and a badge row driven
by frontmatter data. CornerCard is a bordered feature card with corner
brackets, used for the landing page's feature grid.

* Migrate docs content from Hugo to Astro/Starlight

Ports the landing page, user guide sections, FAQ, known issues,
contributing guide, and a sample generated tweak reference page from
the Hugo site, converting Hugo shortcodes and GFM alert syntax to
their Starlight/MDX equivalents.

* Use Windows-style caption buttons in hero window chrome

Swap the macOS traffic-light dots for a minimize/maximize/close
button group, since WinUtil is a Windows tool.

* Use Windows-style caption glyph for terminal code blocks

Replace Expressive Code's default macOS dots on terminal-framed code
blocks with a right-aligned Windows minimize/maximize/close icon,
matching the hero window chrome.

* Archive the Hugo docs site as docs-old

* Promote Astro/Starlight docs from docs-astro to docs

* Show the launch command as a copyable code block on the docs homepage

* Add docs codeowner for seanh1995

* Register custom Header component for Starlight docs site

* Add custom navbar links to Astro docs, matching the old Hugo site's top nav

* Point dev docs generator at the Astro/Starlight docs site

Output moves from docs/content/dev (Hugo) to
docs/src/content/docs/code-reference (Astro/Starlight): .mdx instead
of .md, Starlight-style title="..." code fence labels instead of
Hugo's filename/linenos shortcode, and a ":::note" aside linking back
to each entry's source file. Frontmatter description is now pulled
from the JSON Description field. Also fixes a pre-existing bug where
the embedded JSON snippets were always missing their own closing
brace.

* Add seanh1995 as codeowner for the dev docs generator

* Wire up Code Reference section in docs sidebar

Adds an Architecture & Design page plus autogenerated Tweaks/Features
Reference groups pointing at docs/src/content/docs/code-reference, and
fixes the editLink base URL to the promoted docs/ path.

* Port architecture doc to code-reference and drop stale hyperv sample

Moves the Hugo-era architecture doc into
docs/src/content/docs/code-reference/architecture.mdx: drops the
Hugo-only weight/toc frontmatter, converts the embedded code fences to
Starlight's title="..." syntax, and repoints the "Related
Documentation" links at this site's actual slugs. Also removes the
hand-written reference/tweaks/hyperv.mdx placeholder now that the
generator produces the real page under code-reference/features.

* Keep pre-conversion backup of devdocs-generator.ps1

Snapshot of the script before it was pointed at the Astro/Starlight
docs site, for reference.

* updated workflow

* Update CODEOWNERS

* Hide edit-page link on the docs landing page

The splash-template landing page isn't a source doc meant to be edited
via GitHub like the rest of the guides, so skip showing the link.

* Add site footer with copyright line, matching the old Hugo docs

The Hugo site rendered "© {year} Chris Titus Tech. All rights
reserved." in its footer; Starlight's default footer had no
equivalent. Override it to append the same copyright line below the
existing edit-link/pagination row, and collapse that row entirely
when it has nothing in it (e.g. pages with editUrl disabled and no
prev/next) instead of leaving an empty gap.

* Fix vertical alignment and size of the arrow icon in hero/CTA buttons

The right-arrow icon read as floating above the button label's
baseline. Root cause was partly a genuine optical mismatch (fixed with
a small position nudge scoped to just the arrow icon, so it doesn't
also shift the unaffected GitHub icon) and partly the final CTA's copy
getting wrapped in a <p> by MDX's markdown parser, which behaved
slightly differently under the flex layout than the Hero component's
plain text node. Switching the CTA button's label to a JS string
expression avoids the wrapper and keeps both buttons' markup, and
rendering, identical.

* Use the dark fork-button screenshot in the contributing guide

Drop the unused light-mode variant and point the guide at
Fork-Button-Dark.png instead.

* Remove old Hugo docs site and pre-conversion backup files

The docs have moved to the Astro/Starlight site; the Hugo site
(docs-old/), its workflow backup, and the devdocs-generator.ps1
pre-conversion snapshot are no longer needed.

* keeping ai happy

* Bump sharp to 0.35.3 in docs site
2026-07-31 15:29:00 -05:00

438 lines
17 KiB
PowerShell

<#
.DESCRIPTION
Generates Astro/Starlight markdown docs from config/tweaks.json and config/feature.json.
Run by the GitHub Actions docs workflow before the Astro build.
#>
function Update-Progress {
param (
[Parameter(Mandatory, position=0)]
[string]$StatusMessage,
[Parameter(Mandatory, position=1)]
[ValidateRange(0,100)]
[int]$Percent
)
Write-Progress -Activity "Generating Dev Docs" -Status $StatusMessage -PercentComplete $Percent
}
function Get-RawJsonBlock {
# Returns the raw JSON text and 1-based start line for an item, excluding the "link" property.
param (
[Parameter(Mandatory)]
[string]$ItemName,
[Parameter(Mandatory)]
[AllowEmptyString()]
[string[]]$JsonLines
)
$escapedName = [regex]::Escape($ItemName)
$startIndex = -1
for ($i = 0; $i -lt $JsonLines.Count; $i++) {
if ($JsonLines[$i] -match "^(\s*)`"$escapedName`"\s*:\s*\{") {
$startIndex = $i
break
}
}
if ($startIndex -eq -1) {
Write-Warning "Could not find '$ItemName' in JSON"
return $null
}
# Use brace-depth tracking to find the closing brace
$endIndex = -1
$depth = 1 # We're starting inside the opening brace
for ($i = ($startIndex + 1); $i -lt $JsonLines.Count; $i++) {
$line = $JsonLines[$i]
# Count braces in this line, ignoring those in strings
$inString = $false
$chars = $line.ToCharArray()
for ($k = 0; $k -lt $chars.Count; $k++) {
if ($chars[$k] -eq '"' -and ($k -eq 0 -or $chars[$k-1] -ne '\')) {
$inString = -not $inString
} elseif (-not $inString) {
if ($chars[$k] -eq '{') { $depth++ }
elseif ($chars[$k] -eq '}') { $depth-- }
}
}
# Found the closing brace of the item
if ($depth -eq 0) {
$endIndex = $i
break
}
}
if ($endIndex -eq -1) {
Write-Warning "Could not find closing brace for '$ItemName'"
return $null
}
# Strip trailing "link" property and blank lines before returning
$lastContentIndex = $endIndex - 1
while ($lastContentIndex -gt $startIndex) {
$trimmed = $JsonLines[$lastContentIndex].Trim()
if ($trimmed -eq "" -or $trimmed -match '^"link"') {
$lastContentIndex--
} else {
break
}
}
# Include the item's own closing brace, stripped of the trailing comma that
# only exists to separate it from the next sibling in the parent object.
$closingLine = $JsonLines[$endIndex] -replace ',\s*$', ''
return @{
LineNumber = $startIndex + 1
RawText = (($JsonLines[$startIndex..$lastContentIndex] + $closingLine) -join "`r`n")
}
}
function Get-GeneratedFromNote {
# Builds the Starlight ":::note" aside pointing back at the source file for an entry.
param (
[Parameter(Mandatory)]
[string]$SourceRelativePath
)
$githubUrl = "https://github.com/ChrisTitusTech/winutil/blob/main/$SourceRelativePath"
$note = ":::note`r`n"
$note += "This page is generated from [``$SourceRelativePath``]($githubUrl). Edit the source file and regenerate the docs rather than editing this file directly.`r`n"
$note += ":::`r`n`r`n"
return $note
}
function Get-ButtonFunctionMapping {
# Parses Invoke-WPFButton.ps1 and returns a hashtable of button name -> function name.
param (
[Parameter(Mandatory)]
[string]$ButtonFilePath
)
$mapping = @{}
foreach ($line in (Get-Content -Path $ButtonFilePath)) {
if ($line -match '^\s*"(\w+)"\s*\{(Invoke-\w+)') {
$mapping[$matches[1]] = $matches[2]
}
}
return $mapping
}
function Add-LinkAttributeToJson {
# Updates only the "link" property for each entry in a JSON config file.
# Reads via ConvertFrom-Json for metadata, then edits lines directly to avoid reformatting.
param (
[Parameter(Mandatory)]
[string]$JsonFilePath,
[Parameter(Mandatory)]
[string]$UrlPrefix,
[Parameter(Mandatory)]
[string]$ItemNameToCut
)
$jsonData = Get-Content -Path $JsonFilePath -Raw | ConvertFrom-Json
$lines = [System.Collections.Generic.List[string]](Get-Content -Path $JsonFilePath)
foreach ($item in $jsonData.PSObject.Properties) {
$itemName = $item.Name
$category = $item.Value.category -replace '[^a-zA-Z0-9]', '-'
$displayName = $itemName -replace $ItemNameToCut, ''
$newLink = "$UrlPrefix/$($category.ToLower())/$($displayName.ToLower())"
$escapedName = [regex]::Escape($itemName)
# Find item start line
$startIdx = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match "^\s*`"$escapedName`"\s*:\s*\{") {
$startIdx = $i
break
}
}
if ($startIdx -eq -1) { continue }
# Derive indentation used by top-level properties in the item.
# Prefer existing property indentation to avoid inheriting bad key indentation.
$null = $lines[$startIdx] -match '^(\s*)'
$propIndent = $matches[1] + ' '
$depthProbe = 1
for ($p = $startIdx + 1; $p -lt $lines.Count; $p++) {
$probeLine = $lines[$p]
if ($depthProbe -eq 1 -and $probeLine -match '^(\s*)"[^"]+"\s*:') {
$propIndent = $matches[1]
break
}
$inStringProbe = $false
$probeChars = $probeLine.ToCharArray()
for ($q = 0; $q -lt $probeChars.Count; $q++) {
if ($probeChars[$q] -eq '"' -and ($q -eq 0 -or $probeChars[$q-1] -ne '\')) {
$inStringProbe = -not $inStringProbe
} elseif (-not $inStringProbe) {
if ($probeChars[$q] -eq '{') { $depthProbe++ }
elseif ($probeChars[$q] -eq '}') { $depthProbe-- }
}
}
if ($depthProbe -eq 0) { break }
}
# Scan forward: remove any existing "link" property and find the closing brace.
# Use brace-depth tracking to properly handle nested structures like arrays.
$closeBraceIdx = -1
$depth = 1 # We're starting inside the opening brace of the item
$linesToRemove = @()
for ($j = $startIdx + 1; $j -lt $lines.Count; $j++) {
$line = $lines[$j]
# Check for existing "link" property at top-level (depth 1 before processing braces on this line)
# Match at any indentation level (user may have manually changed indentation)
if ($depth -eq 1 -and $line -match '^\s*"link"\s*:') {
# Mark this line for removal
$linesToRemove += $j
}
# Count braces in this line, ignoring those in strings
$inString = $false
$chars = $line.ToCharArray()
for ($k = 0; $k -lt $chars.Count; $k++) {
if ($chars[$k] -eq '"' -and ($k -eq 0 -or $chars[$k-1] -ne '\')) {
$inString = -not $inString
} elseif (-not $inString) {
if ($chars[$k] -eq '{') { $depth++ }
elseif ($chars[$k] -eq '}') { $depth-- }
}
}
# Found the closing brace of the item
if ($depth -eq 0) {
$closeBraceIdx = $j
break
}
}
# Remove old "link" lines in reverse order to preserve indices
foreach ($idx in ($linesToRemove | Sort-Object -Descending)) {
# If the line before had a trailing comma (from the link property), remove it
if ($idx -gt $startIdx) {
$prevLine = $lines[$idx - 1]
if ($prevLine -match ',\s*$' -and $lines[$idx].Trim() -match '^}') {
$lines[$idx - 1] = $prevLine -replace ',\s*$', ''
}
}
$lines.RemoveAt($idx)
if ($idx -lt $closeBraceIdx) {
$closeBraceIdx--
}
}
# Now insert "link" before the closing brace (consistent position for all items)
if ($closeBraceIdx -ne -1) {
$prevPropIdx = $closeBraceIdx - 1
while ($prevPropIdx -gt $startIdx -and $lines[$prevPropIdx].Trim() -eq '') { $prevPropIdx-- }
if ($lines[$prevPropIdx] -notmatch ',\s*$') {
$lines[$prevPropIdx] = $lines[$prevPropIdx].TrimEnd() + ','
}
$lines.Insert($closeBraceIdx, "$propIndent`"link`": `"$newLink`"")
}
}
Set-Content -Path $JsonFilePath -Value $lines -Encoding utf8
}
# ==============================================================================
# Main
# ==============================================================================
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
$repoRoot = Resolve-Path "$scriptDir/.."
$tweaksJsonPath = "$repoRoot/config/tweaks.json"
$featuresJsonPath = "$repoRoot/config/feature.json"
$tweaksOutputDir = "$repoRoot/docs/src/content/docs/code-reference/tweaks"
$featuresOutputDir = "$repoRoot/docs/src/content/docs/code-reference/features"
$publicFunctionsDir = "$repoRoot/functions/public"
$privateFunctionsDir = "$repoRoot/functions/private"
$itemnametocut = 'WPF(WinUtil|Toggle|Features?|Tweaks?|Panel|Fix(es)?)?'
$baseUrl = "https://winutil.christitus.com"
# Categories with generated docs
$documentedCategories = @(
"Essential Tweaks",
"z__Advanced Tweaks - CAUTION",
"Customize Preferences",
"Performance Plans",
"Features",
"Fixes",
"Legacy Windows Panels",
"Powershell Profile Powershell 7+ Only",
"Remote Access"
)
# Categories where Button entries embed a PS function instead of raw JSON
$functionEmbedCategories = @(
"Fixes",
"Powershell Profile Powershell 7+ Only",
"Remote Access"
)
Update-Progress "Loading JSON files" 10
$tweaks = Get-Content -Path $tweaksJsonPath -Raw | ConvertFrom-Json
$features = Get-Content -Path $featuresJsonPath -Raw | ConvertFrom-Json
Update-Progress "Loading function files" 20
$functionFiles = @{}
Get-ChildItem -Path $publicFunctionsDir -Filter *.ps1 | ForEach-Object {
$functionFiles[$_.BaseName] = @{ Content = (Get-Content -Path $_.FullName -Raw).TrimEnd(); RelativePath = "functions/public/$($_.Name)" }
}
Get-ChildItem -Path $privateFunctionsDir -Filter *.ps1 | ForEach-Object {
$functionFiles[$_.BaseName] = @{ Content = (Get-Content -Path $_.FullName -Raw).TrimEnd(); RelativePath = "functions/private/$($_.Name)" }
}
Update-Progress "Building button-to-function mapping" 30
$buttonFunctionMap = Get-ButtonFunctionMapping -ButtonFilePath "$publicFunctionsDir/Invoke-WPFButton.ps1"
Update-Progress "Updating documentation links in JSON" 40
Add-LinkAttributeToJson -JsonFilePath $tweaksJsonPath -UrlPrefix "$baseUrl/code-reference/tweaks" -ItemNameToCut $itemnametocut
Add-LinkAttributeToJson -JsonFilePath $featuresJsonPath -UrlPrefix "$baseUrl/code-reference/features" -ItemNameToCut $itemnametocut
# Reload lines after link update so line numbers in docs are accurate
$tweaksLines = Get-Content -Path $tweaksJsonPath
$featuresLines = Get-Content -Path $featuresJsonPath
# ==============================================================================
# Clean up old generated .mdx files
# ==============================================================================
Update-Progress "Cleaning up old generated docs" 45
foreach ($dir in @($tweaksOutputDir, $featuresOutputDir)) {
if (-Not (Test-Path -Path $dir)) { continue }
Get-ChildItem -Path $dir -Recurse -Filter *.mdx | Where-Object {
# No category index.mdx pages exist yet. If one is added later as a
# category landing page, uncomment this line to keep it from being wiped.
# $_.Name -ne "index.mdx"
$true
} | Remove-Item -Force
}
# ==============================================================================
# Generate Tweak Documentation
# ==============================================================================
Update-Progress "Generating tweak documentation" 50
$tweakNames = $tweaks.PSObject.Properties.Name
$totalTweaks = $tweakNames.Count
$tweakCount = 0
foreach ($itemName in $tweakNames) {
$item = $tweaks.$itemName
$tweakCount++
if ($item.category -notin $documentedCategories) { continue }
$category = $item.category -replace '[^a-zA-Z0-9]', '-'
$displayName = $itemName -replace $itemnametocut, ''
$categoryDir = "$tweaksOutputDir/$category"
$filename = "$categoryDir/$displayName.mdx"
if (-Not (Test-Path -Path $categoryDir)) { New-Item -ItemType Directory -Path $categoryDir | Out-Null }
$title = $item.Content -replace '"', '\"'
$description = if ($item.Description) { $item.Description -replace '"', '\"' } else { '' }
$content = "---`r`ntitle: `"$title`"`r`ndescription: `"$description`"`r`n---`r`n`r`n"
if ($item.Type -eq "Button") {
$funcName = $buttonFunctionMap[$itemName]
if ($funcName -and $functionFiles.ContainsKey($funcName)) {
$func = $functionFiles[$funcName]
$content += Get-GeneratedFromNote -SourceRelativePath $func.RelativePath
$content += "``````powershell title=`"$($func.RelativePath)`"`r`n"
$content += $func.Content + "`r`n"
$content += "```````r`n"
}
} else {
$jsonBlock = Get-RawJsonBlock -ItemName $itemName -JsonLines $tweaksLines
if ($jsonBlock) {
$content += Get-GeneratedFromNote -SourceRelativePath "config/tweaks.json"
$content += "``````json title=`"config/tweaks.json`"`r`n"
$content += $jsonBlock.RawText + "`r`n"
$content += "```````r`n"
}
if ($item.registry) {
$content += "`r`n## Registry Changes`r`n`r`n"
$content += "Applications and System Components store and retrieve configuration data to modify Windows settings, so we can use the registry to change many settings in one place.`r`n`r`n"
$content += "You can find information about the registry on [Wikipedia](https://en.wikipedia.org/wiki/Windows_Registry) and [Microsoft's Website](https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry).`r`n"
}
}
Set-Content -Path $filename -Value $content -Encoding utf8 -NoNewline
$percent = [Math]::Min(70, 50 + [int](($tweakCount / $totalTweaks) * 20))
Update-Progress "Generating tweak documentation ($tweakCount/$totalTweaks)" $percent
}
# ==============================================================================
# Generate Feature Documentation
# ==============================================================================
Update-Progress "Generating feature documentation" 70
$featureNames = $features.PSObject.Properties.Name
$totalFeatures = $featureNames.Count
$featureCount = 0
foreach ($itemName in $featureNames) {
$item = $features.$itemName
$featureCount++
if ($item.category -notin $documentedCategories) { continue }
if ($itemName -eq "WPFFeatureInstall") { continue }
$category = $item.category -replace '[^a-zA-Z0-9]', '-'
$displayName = $itemName -replace $itemnametocut, ''
$categoryDir = "$featuresOutputDir/$category"
$filename = "$categoryDir/$displayName.mdx"
if (-Not (Test-Path -Path $categoryDir)) { New-Item -ItemType Directory -Path $categoryDir | Out-Null }
$title = $item.Content -replace '"', '\"'
$description = if ($item.Description) { $item.Description -replace '"', '\"' } else { '' }
$content = "---`r`ntitle: `"$title`"`r`ndescription: `"$description`"`r`n---`r`n`r`n"
if ($item.category -in $functionEmbedCategories) {
$funcName = if ($item.function) { $item.function } else { $buttonFunctionMap[$itemName] }
if ($funcName -and $functionFiles.ContainsKey($funcName)) {
$func = $functionFiles[$funcName]
$content += Get-GeneratedFromNote -SourceRelativePath $func.RelativePath
$content += "``````powershell title=`"$($func.RelativePath)`"`r`n"
$content += $func.Content + "`r`n"
$content += "```````r`n"
}
} else {
$jsonBlock = Get-RawJsonBlock -ItemName $itemName -JsonLines $featuresLines
if ($jsonBlock) {
$content += Get-GeneratedFromNote -SourceRelativePath "config/feature.json"
$content += "``````json title=`"config/feature.json`"`r`n"
$content += $jsonBlock.RawText + "`r`n"
$content += "```````r`n"
}
}
Set-Content -Path $filename -Value $content -Encoding utf8 -NoNewline
$percent = [Math]::Min(90, 70 + [int](($featureCount / $totalFeatures) * 20))
Update-Progress "Generating feature documentation ($featureCount/$totalFeatures)" $percent
}
Update-Progress "Process Completed" 100
Write-Host "Documentation generation complete."