-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathBuild-Index.ps1
More file actions
192 lines (158 loc) · 7.31 KB
/
Copy pathBuild-Index.ps1
File metadata and controls
192 lines (158 loc) · 7.31 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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
<#
.SYNOPSIS
Build index.json - the catalog the PaletteShell extension's in-palette
"Browse community scripts" page fetches and installs from.
.DESCRIPTION
Scans every scripts/<author>/*.ps1 file (top level only - PaletteShell itself never
recurses into subfolders, so neither does this), parses each with the same
comment-based-help + attribute conventions PaletteShell uses (via the PowerShell AST -
scripts are never executed just to read their metadata), and writes the result to
index.json at the repo root.
.PARAMETER RepoRoot
Root of the PaletteShellScripts repo. Defaults to the parent of this script's folder.
.PARAMETER OutputPath
Where to write the catalog. Defaults to <RepoRoot>/index.json.
#>
[CmdletBinding()]
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
[string]$OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $OutputPath) {
$OutputPath = Join-Path $RepoRoot 'index.json'
}
# Author folders that don't hold real, shareable scripts.
$ExcludedAuthors = @('_template')
# Mirrors PowerShellScriptParser.ParseCommentHelp in the PaletteShellExtension repo: find the
# first <# ... #> block anywhere in the raw text (not tied to AST position - a script's
# comment help commonly sits after a leading "using module" line, which the built-in
# $ast.GetHelpContent() does not recognize as script-level help) and read .SYNOPSIS /
# .DESCRIPTION out of it line by line.
function Get-CommentHelp {
param([Parameter(Mandatory)][string]$Content)
$result = [pscustomobject]@{ Synopsis = $null; Description = $null }
$blockMatch = [regex]::Match($Content, '<#(.*?)#>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if (-not $blockMatch.Success) {
return $result
}
$section = $null
$buffer = [System.Text.StringBuilder]::new()
foreach ($rawLine in ($blockMatch.Groups[1].Value -replace "`r`n", "`n" -split "`n")) {
$line = $rawLine.Trim()
$keyMatch = [regex]::Match($line, '^\.(?<k>[A-Za-z]+)(?:\s+(?<a>\S+))?\s*$')
if ($keyMatch.Success) {
if ($section -eq 'SYNOPSIS') { $result.Synopsis = $buffer.ToString().Trim() }
elseif ($section -eq 'DESCRIPTION') { $result.Description = $buffer.ToString().Trim() }
$buffer.Clear() | Out-Null
$keyword = $keyMatch.Groups['k'].Value.ToUpperInvariant()
$section = if ($keyword -in @('SYNOPSIS', 'DESCRIPTION')) { $keyword } else { $null }
continue
}
if ($section) {
[void]$buffer.AppendLine($rawLine)
}
}
if ($section -eq 'SYNOPSIS') { $result.Synopsis = $buffer.ToString().Trim() }
elseif ($section -eq 'DESCRIPTION') { $result.Description = $buffer.ToString().Trim() }
if ([string]::IsNullOrWhiteSpace($result.Synopsis)) { $result.Synopsis = $null }
if ([string]::IsNullOrWhiteSpace($result.Description)) { $result.Description = $null }
return $result
}
function Get-ScriptMetadata {
param([Parameter(Mandatory)][string]$Path)
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors -and $parseErrors.Count -gt 0) {
throw "Parse error(s): $($parseErrors -join '; ')"
}
$help = Get-CommentHelp -Content (Get-Content -Path $Path -Raw)
$title = if ($help.Synopsis) { $help.Synopsis } else { [System.IO.Path]::GetFileNameWithoutExtension($Path) }
$description = $help.Description
# [ScriptGroup(...)] / [ScriptIcon(...)] / [ScriptVersion(...)] live on the param block's
# attribute list, ahead of param() itself - read them straight off the AST rather than
# executing anything. ParamBlock is null for a script with no param() at all.
$group = $null
$icon = $null
$version = $null
if ($ast.ParamBlock) {
foreach ($attributeAst in $ast.ParamBlock.Attributes) {
$firstArg = $attributeAst.PositionalArguments | Select-Object -First 1
if ($firstArg -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) {
continue
}
switch ($attributeAst.TypeName.Name) {
'ScriptGroup' { $group = $firstArg.Value }
'ScriptIcon' { $icon = $firstArg.Value }
'ScriptVersion' { $version = $firstArg.Value }
}
}
}
# A script that omits [ScriptVersion(...)] is assumed to be at the baseline version rather
# than having "no version" - keeps every catalog entry comparable instead of the Store
# having to special-case a missing field.
if (-not $version) {
$version = '1.0.0'
}
[pscustomobject]@{
Title = $title
Description = $description
Group = $group
Icon = $icon
Version = $version
}
}
$scriptsRoot = Join-Path $RepoRoot 'scripts'
$entries = [System.Collections.Generic.List[object]]::new()
$hadErrors = $false
Get-ChildItem -Path $scriptsRoot -Directory | ForEach-Object {
$author = $_.Name
if ($ExcludedAuthors -contains $author) {
return
}
Get-ChildItem -Path $_.FullName -Filter '*.ps1' -File | ForEach-Object {
$scriptFile = $_
$relativePath = ('scripts/{0}/{1}' -f $author, $scriptFile.Name).Replace('\', '/')
try {
$meta = Get-ScriptMetadata -Path $scriptFile.FullName
} catch {
Write-Error "Failed to parse ${relativePath}: $_"
$hadErrors = $true
return
}
$sha = (git -C $RepoRoot hash-object $scriptFile.FullName).Trim()
# Commit date of the most recent commit that touched this file - i.e. what GitHub's
# own "Latest commit" file history shows. Requires full history (not a shallow clone);
# on a depth-1 checkout every file would falsely show the same single available commit.
# $null (rather than empty output) when the file has no commits yet, e.g. it's staged
# but not committed - `git log` prints nothing rather than an empty line.
$lastModifiedRaw = git -C $RepoRoot log -1 --format=%cI -- $relativePath
$lastModified = if ($lastModifiedRaw) { $lastModifiedRaw.Trim() } else { $null }
$entries.Add([ordered]@{
path = $relativePath
title = $meta.Title
description = $meta.Description
group = $meta.Group
icon = $meta.Icon
author = $author
tags = @()
sha = $sha
version = $meta.Version
lastModified = $lastModified
})
}
}
if ($hadErrors) {
throw 'One or more scripts failed to parse - see errors above. index.json was not written.'
}
$sortedEntries = $entries | Sort-Object path
$index = [ordered]@{
version = 1
scripts = @($sortedEntries)
}
# Depth needs to comfortably exceed the object graph (script -> string fields + tags array).
$json = $index | ConvertTo-Json -Depth 6
[System.IO.File]::WriteAllText($OutputPath, $json + "`n", [System.Text.UTF8Encoding]::new($false))
Write-Output "Wrote $($entries.Count) script(s) from $((Get-ChildItem -Path $scriptsRoot -Directory | Where-Object { $ExcludedAuthors -notcontains $_.Name }).Count) author folder(s) to $OutputPath"