Skip to content

Commit fc9eb40

Browse files
🚀 [Feature]: Install only the Nerd Font variants you need with faster reruns (#77)
Install-NerdFont can now install only the Nerd Font variants you want, reuse cached archives between runs, and skip work for fonts that are already present in the requested scope. Multi-font installs also finish faster because archive downloads are batched and extraction uses the underlying .NET zip APIs directly. - Fixes #70 - Fixes #71 - Fixes #72 - Fixes #73 - Fixes #74 - Fixes #75 - Fixes #76 ## New: Install only the variants you need Use `-Variant` to limit each archive to the families you actually want to register. This is useful for terminal-focused setups where only the monospace family is needed. ```powershell Install-NerdFont -Name 'FiraCode' -Variant Mono Install-NerdFont -All -Variant Mono ``` `All` remains the default, so existing scripts keep the current behavior unless they opt into `Standard`, `Mono`, or `Propo`. ## Changed: Repeated installs reuse prior work When a font is already installed in the requested scope and `-Force` is not used, `Install-NerdFont` now skips the download, extraction, and install phases for that font. Downloaded archives are cached per Nerd Fonts release, so retries and overlapping reruns can reuse the same zip instead of fetching it again. ```powershell Install-NerdFont -Name 'Hack' Install-NerdFont -Name 'Hack' # skips when already installed Install-NerdFont -Name 'Hack' -Force # re-downloads and reinstalls ``` ## Changed: Multi-font installs complete faster Bulk installs now resolve the target font set once, avoid duplicate work from overlapping name patterns, download archives in bounded batches, and extract them with `System.IO.Compression.ZipFile`. The end result is less waiting during `-All` and other multi-font runs without changing the default install surface. ## Technical Details - `Install-NerdFont` now deduplicates the resolved font list, skips already-installed fonts without `-Force`, caches archives under the user cache directory, downloads uncached archives through `System.Net.Http.HttpClient`, extracts with `System.IO.Compression.ZipFile`, and filters extracted files by `-Variant` before calling `Install-Font`. - `tests/NerdFonts.Tests.ps1` adds coverage for `-Variant Mono` installs. - `scripts/Measure-InstallPerformance.ps1` adds repeatable performance scenarios for single-font, subset, rerun, and optional `-All` measurements. - `README.md` now documents variant installs, skip-on-rerun behavior, and cache bypass with `-Force`. - Implementation plan progress: the work tracked by #70 through #76 is completed in this PR.
1 parent 4d97edc commit fc9eb40

3 files changed

Lines changed: 593 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ To download the font from the NerdFonts repository and install it on the system,
3737
Install-NerdFont -Name 'FiraCode' -Scope AllUsers #Tab completion works on Scope too
3838
```
3939

40+
To install only a specific variant from the archive, use the `-Variant` parameter. `Mono` is useful for terminal and editor setups where you only want the monospace family.
41+
42+
```powershell
43+
Install-NerdFont -Name 'FiraCode' -Variant Mono
44+
```
45+
4046
### Install all NerdFonts
4147

4248
To install all NerdFonts on the system you can use the following command.
@@ -54,6 +60,12 @@ This requires the shell to run in an elevated context (sudo or run as administra
5460
Install-NerdFont -All -Scope AllUsers
5561
```
5662

63+
You can combine `-All` with `-Variant` to limit what gets installed from each archive:
64+
65+
```powershell
66+
Install-NerdFont -All -Variant Mono
67+
```
68+
5769
### Check if a NerdFont is installed
5870

5971
The [Fonts](https://psmodule.io/Fonts) module is installed automatically as a dependency and provides the
@@ -73,6 +85,23 @@ Get-Font -Name 'FiraCode*' -Scope AllUsers
7385

7486
If the command returns results, the font is installed. If it returns nothing, the font is not installed in that scope.
7587

88+
When you run `Install-NerdFont` again without `-Force`, fonts that are already installed in the requested scope are skipped. Downloaded archives are also cached per Nerd Fonts release so retries and repeated installs do not need to fetch the same ZIP again.
89+
90+
Cache locations:
91+
92+
- Windows: `%LOCALAPPDATA%/PSModule/NerdFonts/cache`
93+
- macOS and Linux: `$HOME/.cache/PSModule/NerdFonts`
94+
95+
You can inspect the active cache path in PowerShell with:
96+
97+
```powershell
98+
if ($IsWindows) {
99+
Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PSModule/NerdFonts/cache'
100+
} else {
101+
Join-Path $HOME '.cache/PSModule/NerdFonts'
102+
}
103+
```
104+
76105
### Update an installed NerdFont
77106

78107
Individual font files do not embed a NerdFonts release version, so there is no direct way to check whether an installed
@@ -89,7 +118,7 @@ Install-NerdFont -Name 'FiraCode' -Force -Scope AllUsers
89118
```
90119

91120
This re-downloads and installs the font version bundled with your installed NerdFonts module, overwriting any existing
92-
files. To pick up newer font releases, update the NerdFonts module first (`Update-PSResource -Name NerdFonts` if you
121+
files. `-Force` also bypasses the local archive cache so the font ZIP is fetched again before reinstalling. To pick up newer font releases, update the NerdFonts module first (`Update-PSResource -Name NerdFonts` if you
93122
installed via PSResourceGet, or `Update-Module -Name NerdFonts` if you installed via PowerShellGet).
94123

95124
### Uninstall a NerdFont

‎src/functions/public/Install-NerdFont.ps1‎

Lines changed: 229 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ function Install-NerdFont {
2929
3030
Installs all Nerd Fonts to the current user.
3131
32+
.EXAMPLE
33+
Install-NerdFont -Name 'FiraCode' -Variant Mono
34+
35+
Installs only the monospace variant of the font 'FiraCode' to the current user.
36+
37+
.EXAMPLE
38+
Install-NerdFont -All -Variant Mono
39+
40+
Installs only the monospace variant of all Nerd Fonts to the current user.
41+
3242
.LINK
3343
https://psmodule.io/NerdFonts/Functions/Install-NerdFont
3444
@@ -63,6 +73,11 @@ function Install-NerdFont {
6373
[ValidateSet('CurrentUser', 'AllUsers')]
6474
[string] $Scope = 'CurrentUser',
6575

76+
# Select which variant(s) to install from each archive. Default 'All' preserves current behavior.
77+
[Parameter()]
78+
[ValidateSet('All', 'Standard', 'Mono', 'Propo')]
79+
[string] $Variant = 'All',
80+
6681
# Force will overwrite existing fonts
6782
[Parameter()]
6883
[switch] $Force
@@ -76,7 +91,8 @@ Please run the command again with elevated rights (Run as Administrator) or prov
7691
'@
7792
throw $errorMessage
7893
}
79-
$nerdFontsToInstall = @()
94+
$nerdFontsToInstall = [System.Collections.Generic.List[object]]::new()
95+
$seenNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
8096

8197
$guid = (New-Guid).Guid
8298
$tempPath = Join-Path -Path $HOME -ChildPath "NerdFonts-$guid"
@@ -88,46 +104,243 @@ Please run the command again with elevated rights (Run as Administrator) or prov
88104

89105
process {
90106
if ($All) {
91-
$nerdFontsToInstall = $script:NerdFonts
107+
foreach ($font in $script:NerdFonts) {
108+
if ($seenNames.Add($font.Name)) { $nerdFontsToInstall.Add($font) }
109+
}
92110
} else {
93111
foreach ($fontName in $Name) {
94-
$nerdFontsToInstall += $script:NerdFonts | Where-Object { $_.Name -like $fontName }
112+
foreach ($font in $script:NerdFonts) {
113+
if ($font.Name -like $fontName -and $seenNames.Add($font.Name)) {
114+
$nerdFontsToInstall.Add($font)
115+
}
116+
}
95117
}
96118
}
119+
}
120+
121+
end {
122+
Write-Verbose "[$Scope] - Installing [$($nerdFontsToInstall.Count)] fonts"
97123

98-
Write-Verbose "[$Scope] - Installing [$($nerdFontsToInstall.count)] fonts"
124+
$cacheRoot = if ($IsWindows) {
125+
Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'PSModule/NerdFonts/cache'
126+
} else {
127+
Join-Path -Path $HOME -ChildPath '.cache/PSModule/NerdFonts'
128+
}
99129

130+
$installedFamilies = $null
131+
if (-not $Force) {
132+
$installedNames = @(Get-Font -Scope $Scope -ErrorAction SilentlyContinue | ForEach-Object { $_.Name } | Where-Object { $_ })
133+
$installedFamilies = [System.Collections.Generic.HashSet[string]]::new(
134+
[string[]]$installedNames,
135+
[System.StringComparer]::OrdinalIgnoreCase
136+
)
137+
}
138+
139+
$toProcess = [System.Collections.Generic.List[object]]::new()
100140
foreach ($nerdFont in $nerdFontsToInstall) {
101-
$URL = $nerdFont.URL
102141
$fontName = $nerdFont.Name
103-
$downloadFileName = Split-Path -Path $URL -Leaf
104-
$downloadPath = Join-Path -Path $tempPath -ChildPath $downloadFileName
142+
if (-not $Force -and $installedFamilies) {
143+
$alreadyInstalled = $false
144+
foreach ($family in $installedFamilies) {
145+
if ($family -like "$fontName Nerd Font*") { $alreadyInstalled = $true; break }
146+
}
147+
if ($alreadyInstalled) {
148+
Write-Verbose "[$fontName] - already installed, skipping"
149+
continue
150+
}
151+
}
152+
$toProcess.Add($nerdFont)
153+
}
105154

106-
Write-Verbose "[$fontName] - Downloading to [$downloadPath]"
107-
if ($PSCmdlet.ShouldProcess("[$fontName] to [$downloadPath]", 'Download')) {
108-
Invoke-WebRequest -Uri $URL -OutFile $downloadPath -RetryIntervalSec 5 -MaximumRetryCount 5
155+
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
156+
$httpClient = [System.Net.Http.HttpClient]::new()
157+
# Keep request lifetime unbounded for large archives on slower links.
158+
$httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
159+
$pending = [System.Collections.Generic.List[object]]::new()
160+
$readyToInstall = [System.Collections.Generic.List[object]]::new()
161+
$downloadErrors = [System.Collections.Generic.List[string]]::new()
162+
$throttle = 8
163+
164+
try {
165+
foreach ($nerdFont in $toProcess) {
166+
$URL = $nerdFont.URL
167+
$fontName = $nerdFont.Name
168+
$downloadFileName = Split-Path -Path $URL -Leaf
169+
$downloadPath = Join-Path -Path $tempPath -ChildPath $downloadFileName
170+
171+
$cacheTag = if ($URL -match '/releases/download/([^/]+)/') {
172+
$Matches[1]
173+
} else {
174+
'unknown'
175+
}
176+
$cacheTagDir = Join-Path -Path $cacheRoot -ChildPath $cacheTag
177+
$cachedFile = Join-Path -Path $cacheTagDir -ChildPath $downloadFileName
178+
179+
if ((Test-Path -LiteralPath $cachedFile) -and -not $Force) {
180+
Write-Verbose "[$fontName] - Cache hit at [$cachedFile]"
181+
$cacheHitSuccess = $false
182+
try {
183+
Copy-Item -LiteralPath $cachedFile -Destination $downloadPath -Force -ErrorAction Stop
184+
$cacheHitSuccess = $true
185+
} catch {
186+
Write-Warning "[$fontName] - Cache read failed, falling back to download: $($_.Exception.Message)"
187+
}
188+
if ($cacheHitSuccess) {
189+
$item = [pscustomobject]@{
190+
Name = $fontName
191+
URL = $URL
192+
DownloadPath = $downloadPath
193+
CachedFile = $cachedFile
194+
CacheTagDir = $cacheTagDir
195+
FromCache = $true
196+
}
197+
$pending.Add($item)
198+
$readyToInstall.Add($item)
199+
} else {
200+
$item = [pscustomobject]@{
201+
Name = $fontName
202+
URL = $URL
203+
DownloadPath = $downloadPath
204+
CachedFile = $cachedFile
205+
CacheTagDir = $cacheTagDir
206+
FromCache = $false
207+
}
208+
$pending.Add($item)
209+
}
210+
} else {
211+
Write-Verbose "[$fontName] - Queue download to [$downloadPath]"
212+
$item = [pscustomobject]@{
213+
Name = $fontName
214+
URL = $URL
215+
DownloadPath = $downloadPath
216+
CachedFile = $cachedFile
217+
CacheTagDir = $cacheTagDir
218+
FromCache = $false
219+
}
220+
$pending.Add($item)
221+
}
109222
}
110223

224+
$toDownload = @($pending | Where-Object { -not $_.FromCache })
225+
for ($i = 0; $i -lt $toDownload.Count; $i += $throttle) {
226+
$end = [Math]::Min($i + $throttle - 1, $toDownload.Count - 1)
227+
$chunk = $toDownload[$i..$end]
228+
$tasks = @()
229+
foreach ($q in $chunk) {
230+
$tasks += [pscustomobject]@{ Q = $q; Task = $httpClient.GetByteArrayAsync($q.URL) }
231+
}
232+
foreach ($t in $tasks) {
233+
try {
234+
$bytes = $t.Task.GetAwaiter().GetResult()
235+
[System.IO.File]::WriteAllBytes($t.Q.DownloadPath, $bytes)
236+
$readyToInstall.Add($t.Q)
237+
} catch {
238+
$downloadErrors.Add("[$($t.Q.Name)] - Download failed: $($_.Exception.Message)")
239+
}
240+
}
241+
}
242+
} finally {
243+
$httpClient.Dispose()
244+
}
245+
246+
foreach ($p in $readyToInstall) {
247+
$fontName = $p.Name
248+
$downloadPath = $p.DownloadPath
111249
$extractPath = Join-Path -Path $tempPath -ChildPath $fontName
112250
Write-Verbose "[$fontName] - Extract to [$extractPath]"
113251
if ($PSCmdlet.ShouldProcess("[$fontName] to [$extractPath]", 'Extract')) {
114-
Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force
115-
Remove-Item -Path $downloadPath -Force
252+
if (-not (Test-Path -LiteralPath $extractPath)) {
253+
$null = New-Item -ItemType Directory -Path $extractPath
254+
}
255+
[System.IO.Compression.ZipFile]::ExtractToDirectory($downloadPath, $extractPath, $true)
256+
257+
if (-not $p.FromCache -and (Test-Path -LiteralPath $downloadPath)) {
258+
$tempCachePath = $null
259+
try {
260+
if (-not (Test-Path -LiteralPath $p.CacheTagDir)) {
261+
$null = New-Item -ItemType Directory -Path $p.CacheTagDir -Force -ErrorAction Stop
262+
}
263+
$tempCachePath = "$($p.CachedFile).$PID.tmp"
264+
Copy-Item -LiteralPath $downloadPath -Destination $tempCachePath -Force -ErrorAction Stop
265+
Move-Item -LiteralPath $tempCachePath -Destination $p.CachedFile -Force -ErrorAction Stop
266+
} catch {
267+
Write-Warning "[$fontName] - Download succeeded but cache write failed: $($_.Exception.Message)"
268+
if ($tempCachePath -and (Test-Path -LiteralPath $tempCachePath)) {
269+
Remove-Item -LiteralPath $tempCachePath -Force -ErrorAction SilentlyContinue
270+
}
271+
}
272+
}
273+
274+
Remove-Item -LiteralPath $downloadPath -Force -ErrorAction SilentlyContinue
275+
}
276+
277+
if ($Variant -ne 'All') {
278+
$allFiles = Get-ChildItem -Path $extractPath -Recurse -File -Include '*.ttf', '*.otf'
279+
$keep = switch ($Variant) {
280+
'Mono' {
281+
$allFiles | Where-Object { $_.Name -like '*NerdFontMono*' }
282+
}
283+
'Propo' {
284+
$allFiles | Where-Object { $_.Name -like '*NerdFontPropo*' }
285+
}
286+
'Standard' {
287+
$allFiles | Where-Object {
288+
$_.Name -like '*NerdFont*' -and
289+
$_.Name -notlike '*NerdFontMono*' -and
290+
$_.Name -notlike '*NerdFontPropo*'
291+
}
292+
}
293+
}
294+
$keepNames = [string[]]@($keep.FullName)
295+
$keepSet = [System.Collections.Generic.HashSet[string]]::new(
296+
$keepNames,
297+
[System.StringComparer]::OrdinalIgnoreCase
298+
)
299+
$removed = 0
300+
foreach ($f in $allFiles) {
301+
if (-not $keepSet.Contains($f.FullName)) {
302+
Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue
303+
$removed++
304+
}
305+
}
306+
Write-Verbose "[$fontName] - Variant '$Variant': kept $($keep.Count), removed $removed"
307+
}
308+
309+
# Nerd Fonts archives sometimes contain duplicate matching files in
310+
# compatibility subfolders. Keep a single file per filename.
311+
$remaining = @(Get-ChildItem -Path $extractPath -Recurse -File -Include '*.ttf', '*.otf')
312+
$preferred = $remaining | Sort-Object -Property @(
313+
@{ Expression = { if ($_.FullName -match '(?i)[\\/]Windows Compatible[\\/]') { 1 } else { 0 } } }
314+
@{ Expression = { $_.FullName.Length } }
315+
)
316+
$seenFileNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
317+
$duplicateRemoved = 0
318+
foreach ($file in $preferred) {
319+
if ($seenFileNames.Add($file.Name)) { continue }
320+
Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
321+
$duplicateRemoved++
322+
}
323+
if ($duplicateRemoved -gt 0) {
324+
Write-Verbose "[$fontName] - Deduplicated $duplicateRemoved file(s)"
116325
}
117326

118327
Write-Verbose "[$fontName] - Install to [$Scope]"
119328
if ($PSCmdlet.ShouldProcess("[$fontName] to [$Scope]", 'Install font')) {
120329
Install-Font -Path $extractPath -Scope $Scope -Force:$Force
121-
Remove-Item -Path $extractPath -Force -Recurse
330+
Remove-Item -LiteralPath $extractPath -Force -Recurse -ErrorAction SilentlyContinue
122331
}
123332
}
124-
}
125333

126-
end {
334+
foreach ($err in $downloadErrors) {
335+
Write-Error $err
336+
}
337+
127338
Write-Verbose "Remove folder [$tempPath]"
128339
}
129340

130341
clean {
131-
Remove-Item -Path $tempPath -Force
342+
if ($tempPath -and (Test-Path -LiteralPath $tempPath)) {
343+
Remove-Item -LiteralPath $tempPath -Force -Recurse -ErrorAction SilentlyContinue
344+
}
132345
}
133346
}

0 commit comments

Comments
 (0)