Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4f46dd1
perf: add Install-NerdFont performance measurement script
MariusStorhaug May 17, 2026
e63bfc1
perf: suppress Invoke-WebRequest progress bar during font downloads
MariusStorhaug May 17, 2026
acb9884
perf: dedup font set with List+HashSet, drop O(n^2) array growth
MariusStorhaug May 17, 2026
ddd7142
perf: skip download when font is already installed (closes #73)
MariusStorhaug May 17, 2026
414f423
fix: handle empty Get-Font result in skip-installed check
MariusStorhaug May 17, 2026
c3eddbf
perf: extract font archives via ZipFile API (closes #72)
MariusStorhaug May 17, 2026
955ea2c
perf: cache downloaded font archives between invocations (closes #76)
MariusStorhaug May 17, 2026
3f208ea
perf: add -Variant filter to install only desired font variants (clos…
MariusStorhaug May 17, 2026
6df13c6
style: split long Variant switch arms for PSUseConsistentWhitespace/P…
MariusStorhaug May 17, 2026
7a054ea
test: add -Variant Mono case to lift code coverage above threshold
MariusStorhaug May 17, 2026
ddd8014
perf: parallelize font downloads/extract (closes #71)
MariusStorhaug May 17, 2026
d041a0b
perf: parallel HTTP downloads via HttpClient tasks (closes #71)
MariusStorhaug May 17, 2026
1572b9a
perf: bound parallel HTTP downloads to 8 concurrent (closes #71)
MariusStorhaug May 17, 2026
ed569aa
Document variant installs and cache behavior
MariusStorhaug May 17, 2026
62233a5
Handle partial download failures in Install-NerdFont
MariusStorhaug May 17, 2026
4ea2bdc
Fix Install-NerdFont indentation for CI
MariusStorhaug May 17, 2026
5d90644
Normalize queued download object indentation
MariusStorhaug May 17, 2026
cadd8cf
Add coverage for installed-font skip path
MariusStorhaug May 17, 2026
5d052b7
Use processor count for default parallelism
MariusStorhaug May 17, 2026
a7223f4
Remove perf-results.jsonl from .gitignore and add the file with perfo…
MariusStorhaug May 17, 2026
a63442d
Increase Install-NerdFont coverage with Standard variant test
MariusStorhaug May 17, 2026
d8d51c2
Fix formatting of Write-Host output in Measure-InstallPerformance.ps1
MariusStorhaug May 17, 2026
34de022
Stabilize all-font test and fix recursive temp cleanup
MariusStorhaug May 17, 2026
5183ed2
Address PR #77 review threads: cache resilience, variant dedupe, docs…
MariusStorhaug May 18, 2026
1a4120e
Fix Sort-Object property expressions not being passed to the command;…
MariusStorhaug May 18, 2026
75feaf4
Run duplicate-file removal for all variant selections, not just non-All
MariusStorhaug May 18, 2026
634c91c
Fix prefix false-positive in skip check, cap download throttle at 8, …
MariusStorhaug May 18, 2026
2b35f57
Fix cache resilience and strengthen variant test assertions
MariusStorhaug May 18, 2026
ef4c99f
Add test for cache-read failure fallback to improve coverage
MariusStorhaug May 18, 2026
00d4a36
Fix CI test failures: isolate variant tests from Fonts module, fix ca…
MariusStorhaug May 19, 2026
f3f71bd
Defer cache directory creation until approved by ShouldProcess; fix p…
MariusStorhaug May 19, 2026
f8137f2
Clean up test-created cache directories so tests leave no persistent …
MariusStorhaug May 19, 2026
5262f49
Refactor tests to use InModuleScope for module-level coverage; add Pr…
MariusStorhaug May 19, 2026
2ae527f
Fix CI test failures and address cache write review threads
MariusStorhaug May 19, 2026
9c42642
Add -Variant help examples; defer download errors until after batch i…
MariusStorhaug May 20, 2026
9d69e61
Fix cross-platform path construction in tests; guard null tempCachePa…
MariusStorhaug May 20, 2026
0316076
Use named parameters in Join-Path calls to fix PSAvoidUsingPositional…
MariusStorhaug May 20, 2026
e8353a2
Remove performance measurement script and results file
MariusStorhaug May 20, 2026
e67e99f
Make temp-file cleanup best-effort to prevent abort under -ErrorActio…
MariusStorhaug May 20, 2026
8a13f22
Initialize $tempCachePath to $null before try block to prevent stale …
MariusStorhaug May 20, 2026
5705700
Remove unnecessary -Force from variant tests to enable cache reuse
MariusStorhaug May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ To download the font from the NerdFonts repository and install it on the system,
Install-NerdFont -Name 'FiraCode' -Scope AllUsers #Tab completion works on Scope too
```

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.

```powershell
Install-NerdFont -Name 'FiraCode' -Variant Mono
```

### Install all NerdFonts

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

You can combine `-All` with `-Variant` to limit what gets installed from each archive:

```powershell
Install-NerdFont -All -Variant Mono
```

### Check if a NerdFont is installed

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

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

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.

Comment thread
MariusStorhaug marked this conversation as resolved.
Cache locations:

- Windows: `%LOCALAPPDATA%/PSModule/NerdFonts/cache`
- macOS and Linux: `$HOME/.cache/PSModule/NerdFonts`

You can inspect the active cache path in PowerShell with:

```powershell
if ($IsWindows) {
Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PSModule/NerdFonts/cache'
} else {
Join-Path $HOME '.cache/PSModule/NerdFonts'
}
```

### Update an installed NerdFont

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

This re-downloads and installs the font version bundled with your installed NerdFonts module, overwriting any existing
files. To pick up newer font releases, update the NerdFonts module first (`Update-PSResource -Name NerdFonts` if you
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
installed via PSResourceGet, or `Update-Module -Name NerdFonts` if you installed via PowerShellGet).

### Uninstall a NerdFont
Expand Down
245 changes: 229 additions & 16 deletions src/functions/public/Install-NerdFont.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ function Install-NerdFont {

Installs all Nerd Fonts to the current user.

.EXAMPLE
Install-NerdFont -Name 'FiraCode' -Variant Mono

Installs only the monospace variant of the font 'FiraCode' to the current user.

.EXAMPLE
Install-NerdFont -All -Variant Mono

Installs only the monospace variant of all Nerd Fonts to the current user.

.LINK
https://psmodule.io/NerdFonts/Functions/Install-NerdFont

Expand Down Expand Up @@ -63,6 +73,11 @@ function Install-NerdFont {
[ValidateSet('CurrentUser', 'AllUsers')]
[string] $Scope = 'CurrentUser',

# Select which variant(s) to install from each archive. Default 'All' preserves current behavior.
[Parameter()]
[ValidateSet('All', 'Standard', 'Mono', 'Propo')]
[string] $Variant = 'All',
Comment thread
MariusStorhaug marked this conversation as resolved.

# Force will overwrite existing fonts
[Parameter()]
[switch] $Force
Comment thread
MariusStorhaug marked this conversation as resolved.
Expand All @@ -76,7 +91,8 @@ Please run the command again with elevated rights (Run as Administrator) or prov
'@
throw $errorMessage
}
$nerdFontsToInstall = @()
$nerdFontsToInstall = [System.Collections.Generic.List[object]]::new()
$seenNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

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

process {
if ($All) {
$nerdFontsToInstall = $script:NerdFonts
foreach ($font in $script:NerdFonts) {
if ($seenNames.Add($font.Name)) { $nerdFontsToInstall.Add($font) }
}
} else {
foreach ($fontName in $Name) {
$nerdFontsToInstall += $script:NerdFonts | Where-Object { $_.Name -like $fontName }
foreach ($font in $script:NerdFonts) {
if ($font.Name -like $fontName -and $seenNames.Add($font.Name)) {
$nerdFontsToInstall.Add($font)
Comment thread
MariusStorhaug marked this conversation as resolved.
}
}
}
}
}

end {
Write-Verbose "[$Scope] - Installing [$($nerdFontsToInstall.Count)] fonts"

Write-Verbose "[$Scope] - Installing [$($nerdFontsToInstall.count)] fonts"
$cacheRoot = if ($IsWindows) {
Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'PSModule/NerdFonts/cache'
Comment thread
MariusStorhaug marked this conversation as resolved.
} else {
Join-Path -Path $HOME -ChildPath '.cache/PSModule/NerdFonts'
Comment thread
MariusStorhaug marked this conversation as resolved.
}

$installedFamilies = $null
if (-not $Force) {
$installedNames = @(Get-Font -Scope $Scope -ErrorAction SilentlyContinue | ForEach-Object { $_.Name } | Where-Object { $_ })
$installedFamilies = [System.Collections.Generic.HashSet[string]]::new(
[string[]]$installedNames,
[System.StringComparer]::OrdinalIgnoreCase
)
}

$toProcess = [System.Collections.Generic.List[object]]::new()
foreach ($nerdFont in $nerdFontsToInstall) {
Comment thread
MariusStorhaug marked this conversation as resolved.
$URL = $nerdFont.URL
$fontName = $nerdFont.Name
$downloadFileName = Split-Path -Path $URL -Leaf
$downloadPath = Join-Path -Path $tempPath -ChildPath $downloadFileName
if (-not $Force -and $installedFamilies) {
$alreadyInstalled = $false
foreach ($family in $installedFamilies) {
if ($family -like "$fontName Nerd Font*") { $alreadyInstalled = $true; break }
}
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.
if ($alreadyInstalled) {
Comment thread
MariusStorhaug marked this conversation as resolved.
Write-Verbose "[$fontName] - already installed, skipping"
continue
Comment thread
MariusStorhaug marked this conversation as resolved.
}
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.
}
$toProcess.Add($nerdFont)
}

Write-Verbose "[$fontName] - Downloading to [$downloadPath]"
if ($PSCmdlet.ShouldProcess("[$fontName] to [$downloadPath]", 'Download')) {
Invoke-WebRequest -Uri $URL -OutFile $downloadPath -RetryIntervalSec 5 -MaximumRetryCount 5
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
$httpClient = [System.Net.Http.HttpClient]::new()
# Keep request lifetime unbounded for large archives on slower links.
$httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
$pending = [System.Collections.Generic.List[object]]::new()
$readyToInstall = [System.Collections.Generic.List[object]]::new()
$downloadErrors = [System.Collections.Generic.List[string]]::new()
$throttle = 8

try {
foreach ($nerdFont in $toProcess) {
$URL = $nerdFont.URL
$fontName = $nerdFont.Name
$downloadFileName = Split-Path -Path $URL -Leaf
$downloadPath = Join-Path -Path $tempPath -ChildPath $downloadFileName

$cacheTag = if ($URL -match '/releases/download/([^/]+)/') {
$Matches[1]
} else {
'unknown'
}
$cacheTagDir = Join-Path -Path $cacheRoot -ChildPath $cacheTag
$cachedFile = Join-Path -Path $cacheTagDir -ChildPath $downloadFileName

if ((Test-Path -LiteralPath $cachedFile) -and -not $Force) {
Write-Verbose "[$fontName] - Cache hit at [$cachedFile]"
$cacheHitSuccess = $false
try {
Copy-Item -LiteralPath $cachedFile -Destination $downloadPath -Force -ErrorAction Stop
$cacheHitSuccess = $true
} catch {
Write-Warning "[$fontName] - Cache read failed, falling back to download: $($_.Exception.Message)"
}
if ($cacheHitSuccess) {
$item = [pscustomobject]@{
Name = $fontName
URL = $URL
DownloadPath = $downloadPath
CachedFile = $cachedFile
CacheTagDir = $cacheTagDir
FromCache = $true
}
$pending.Add($item)
$readyToInstall.Add($item)
} else {
$item = [pscustomobject]@{
Name = $fontName
URL = $URL
DownloadPath = $downloadPath
CachedFile = $cachedFile
CacheTagDir = $cacheTagDir
FromCache = $false
}
$pending.Add($item)
}
} else {
Write-Verbose "[$fontName] - Queue download to [$downloadPath]"
$item = [pscustomobject]@{
Name = $fontName
URL = $URL
DownloadPath = $downloadPath
CachedFile = $cachedFile
CacheTagDir = $cacheTagDir
FromCache = $false
}
$pending.Add($item)
Comment thread
MariusStorhaug marked this conversation as resolved.
}
}

$toDownload = @($pending | Where-Object { -not $_.FromCache })
for ($i = 0; $i -lt $toDownload.Count; $i += $throttle) {
$end = [Math]::Min($i + $throttle - 1, $toDownload.Count - 1)
$chunk = $toDownload[$i..$end]
$tasks = @()
foreach ($q in $chunk) {
$tasks += [pscustomobject]@{ Q = $q; Task = $httpClient.GetByteArrayAsync($q.URL) }
}
foreach ($t in $tasks) {
try {
$bytes = $t.Task.GetAwaiter().GetResult()
[System.IO.File]::WriteAllBytes($t.Q.DownloadPath, $bytes)
$readyToInstall.Add($t.Q)
} catch {
$downloadErrors.Add("[$($t.Q.Name)] - Download failed: $($_.Exception.Message)")
Comment thread
MariusStorhaug marked this conversation as resolved.
}
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.
}
}
} finally {
$httpClient.Dispose()
}

foreach ($p in $readyToInstall) {
$fontName = $p.Name
$downloadPath = $p.DownloadPath
$extractPath = Join-Path -Path $tempPath -ChildPath $fontName
Write-Verbose "[$fontName] - Extract to [$extractPath]"
if ($PSCmdlet.ShouldProcess("[$fontName] to [$extractPath]", 'Extract')) {
Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force
Remove-Item -Path $downloadPath -Force
if (-not (Test-Path -LiteralPath $extractPath)) {
$null = New-Item -ItemType Directory -Path $extractPath
}
[System.IO.Compression.ZipFile]::ExtractToDirectory($downloadPath, $extractPath, $true)

if (-not $p.FromCache -and (Test-Path -LiteralPath $downloadPath)) {
$tempCachePath = $null
try {
if (-not (Test-Path -LiteralPath $p.CacheTagDir)) {
$null = New-Item -ItemType Directory -Path $p.CacheTagDir -Force -ErrorAction Stop
}
$tempCachePath = "$($p.CachedFile).$PID.tmp"
Copy-Item -LiteralPath $downloadPath -Destination $tempCachePath -Force -ErrorAction Stop
Move-Item -LiteralPath $tempCachePath -Destination $p.CachedFile -Force -ErrorAction Stop
} catch {
Comment thread
MariusStorhaug marked this conversation as resolved.
Write-Warning "[$fontName] - Download succeeded but cache write failed: $($_.Exception.Message)"
if ($tempCachePath -and (Test-Path -LiteralPath $tempCachePath)) {
Remove-Item -LiteralPath $tempCachePath -Force -ErrorAction SilentlyContinue
}
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.
}
}

Remove-Item -LiteralPath $downloadPath -Force -ErrorAction SilentlyContinue
}

if ($Variant -ne 'All') {
Comment thread
MariusStorhaug marked this conversation as resolved.
$allFiles = Get-ChildItem -Path $extractPath -Recurse -File -Include '*.ttf', '*.otf'
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.
$keep = switch ($Variant) {
'Mono' {
$allFiles | Where-Object { $_.Name -like '*NerdFontMono*' }
}
'Propo' {
$allFiles | Where-Object { $_.Name -like '*NerdFontPropo*' }
}
'Standard' {
$allFiles | Where-Object {
$_.Name -like '*NerdFont*' -and
$_.Name -notlike '*NerdFontMono*' -and
$_.Name -notlike '*NerdFontPropo*'
}
}
}
$keepNames = [string[]]@($keep.FullName)
$keepSet = [System.Collections.Generic.HashSet[string]]::new(
$keepNames,
[System.StringComparer]::OrdinalIgnoreCase
)
$removed = 0
foreach ($f in $allFiles) {
if (-not $keepSet.Contains($f.FullName)) {
Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue
$removed++
}
}
Write-Verbose "[$fontName] - Variant '$Variant': kept $($keep.Count), removed $removed"
}

# Nerd Fonts archives sometimes contain duplicate matching files in
# compatibility subfolders. Keep a single file per filename.
$remaining = @(Get-ChildItem -Path $extractPath -Recurse -File -Include '*.ttf', '*.otf')
Comment thread
MariusStorhaug marked this conversation as resolved.
$preferred = $remaining | Sort-Object -Property @(
@{ Expression = { if ($_.FullName -match '(?i)[\\/]Windows Compatible[\\/]') { 1 } else { 0 } } }
@{ Expression = { $_.FullName.Length } }
)
$seenFileNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$duplicateRemoved = 0
foreach ($file in $preferred) {
if ($seenFileNames.Add($file.Name)) { continue }
Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
$duplicateRemoved++
}
if ($duplicateRemoved -gt 0) {
Write-Verbose "[$fontName] - Deduplicated $duplicateRemoved file(s)"
}

Write-Verbose "[$fontName] - Install to [$Scope]"
if ($PSCmdlet.ShouldProcess("[$fontName] to [$Scope]", 'Install font')) {
Install-Font -Path $extractPath -Scope $Scope -Force:$Force
Remove-Item -Path $extractPath -Force -Recurse
Remove-Item -LiteralPath $extractPath -Force -Recurse -ErrorAction SilentlyContinue
}
}
}

end {
foreach ($err in $downloadErrors) {
Write-Error $err
}

Write-Verbose "Remove folder [$tempPath]"
}

clean {
Remove-Item -Path $tempPath -Force
if ($tempPath -and (Test-Path -LiteralPath $tempPath)) {
Remove-Item -LiteralPath $tempPath -Force -Recurse -ErrorAction SilentlyContinue
}
}
}
Loading
Loading