Sunday, 25 January 2026

Rendering Blazor components using HtmlRenderer

Rendering Blazor Components Dynamically with HtmlRenderer in .NET 8

🚀 Rendering Blazor Components Dynamically with HtmlRenderer in .NET 8

A practical example using a generic component renderer

Source code available here:
👉 https://github.com/toreaurstadboss/BlazorIntroCourse/tree/main/Components/Demos/TemplatedComponents

Blazor has always been about component‑driven UI, but until .NET 8, components were tightly coupled to the Blazor runtime — either WebAssembly or Server. With the introduction of HtmlRenderer, that boundary disappears.

You can now render any .razor component into pure HTML on the server, without a browser, without WebAssembly, and without a running Blazor app. This opens up a whole new world of scenarios:

  • Rendering components inside MVC, Razor Pages, or Minimal APIs
  • Generating HTML for emails, PDFs, reports, or static site generation
  • Using Blazor components as server‑side templates
  • Building dynamic component renderers that choose components at runtime
  • Running components in background services or unit tests

In this post, I’ll walk through a practical example: A generic Blazor component that uses HtmlRenderer to render any component dynamically, and a simple Bootstrap‑style Alert component to demonstrate how it works.


🎯 Why HtmlRenderer Matters

Here’s the short version — arguments you can inform other developers why HtmlRenderer opens up so many possibilities for using Blazor components many places:

  • Render Blazor components anywhere — MVC, Razor Pages, Minimal APIs, background jobs.
  • Generate static HTML — perfect for emails, PDFs, SEO, caching, and static sites.
  • Use Blazor components as templates — no need for Razor Views or TagHelpers.
  • Full component lifecycle — DI, parameters, cascading values, child content all work.
  • No browser required — everything runs server‑side.
  • Dynamic component composition — choose components at runtime using generics.
  • Great for testing — render components without a browser or JS runtime.

🖼️ Diagram: How HtmlRenderer Works

Your Blazor Component (e.g., <Alert>) Generic Renderer (TComponent + parameters) HtmlRenderer .NET 8 server-side renderer HTML Output (string / MarkupString)

📦Sample Blazor component used | Alert.razor — A Simple Bootstrap‑Style Alert Component


<div 
    class=@($"alert {(IsDismissable ? "alert-dismissible fade show" : "")} alert-{AlertType.ToString().ToLower()}")
    role="alert">
    @if (IsDismissable)
    {
        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
    }
    @ChildContent
</div>

@code {

    [Parameter]
    public required RenderFragment ChildContent { get; set; } = @<b>Default message</b>;

    [Parameter]
    public bool IsDismissable { get; set; }

    [Parameter]
    public AlertTypeEnum AlertType { get; set; } = AlertTypeEnum.Success;

}


🧩 The alert type enum | AlertTypeEnum.cs


namespace DependencyInjectionDemo.Components.Demos.TemplatedComponents
{
    public enum AlertTypeEnum
    {
        Primary,
        Secondary,
        Success,
        Danger,
        Warning,
        Info,
        Light,
        Dark
    }
}


🧠 The Generic HTML Renderer Component | GenericHtmlRenderer.razor


@((MarkupString)RenderedHtml)

@typeparam TComponent

@code {
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public Dictionary<string, object?> Parameters { get; set; } = new();

    [Inject] IServiceProvider ServiceProvider { get; set; } = default!;
    [Inject] ILoggerFactory LoggerFactory { get; set; } = default!;

    private string RenderedHtml { get; set; } = string.Empty;

    protected override async Task OnInitializedAsync()
    {
        if (ChildContent != null)
        {
            Parameters["ChildContent"] = ChildContent;
        }

        RenderedHtml = await RenderComponentAsync(Parameters);
    }

    private async Task<string> RenderComponentAsync(Dictionary<string, object?> parameters)
    {
        if (!typeof(IComponent).IsAssignableFrom(typeof(TComponent)))
        {
            throw new InvalidOperationException($"{typeof(TComponent).Name} is not a valid Blazor component.");
        }

        using var htmlRenderer = new HtmlRenderer(ServiceProvider, LoggerFactory);
        var parameterView = ParameterView.FromDictionary(parameters);

        var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
        {
            var result = await htmlRenderer.RenderComponentAsync(typeof(TComponent), parameterView);
            return result.ToHtmlString();
        });

        return html;
    }
}

Note that we must also check if the parameters sent in here actually are present in the component. That means the parameter is a public property with attribute Parameter. The revised code of the method RenderComponentAsync is shown below.

private async Task<string> RenderComponentAsync(Dictionary<string, object?> parameters)
{

    if (!typeof(IComponent).IsAssignableFrom(typeof(TComponent)))
    {
        throw new InvalidOperationException($"{typeof(TComponent).Name} is not a valid Blazor component.");
    }

    using var htmlRenderer = new HtmlRenderer(ServiceProvider, LoggerFactory);

    var filteredParameters = GetValidParameters(parameters);

    var parameterView = ParameterView.FromDictionary(filteredParameters);

    var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
    {
        var result = await htmlRenderer.RenderComponentAsync(typeof(TComponent), parameterView);
        return result.ToHtmlString();
    });

    return html;
}

private Dictionary<string, object?> GetValidParameters(Dictionary<string, object?> parameters)
{
    var validnames = typeof(TComponent).GetProperties(BindingFlags.Public | BindingFlags.Instance)
        .Where(p => p.IsDefined(typeof(ParameterAttribute), true))
        .Select(p => p.Name)
        .ToHashSet(StringComparer.OrdinalIgnoreCase);   
    validnames.Add("ChildContent");

    var filteredParameters = parameters
        .Where(kvp => validnames.Contains(kvp.Key))
        .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);

    var unknownKeys = parameters.Keys.Except(filteredParameters.Keys, StringComparer.OrdinalIgnoreCase).ToList();
    if (unknownKeys.Count > 0)
    {
        var logger = LoggerFactory.CreateLogger("GenericHtmlRenderer");
        string warningmsg = $"Dropped unknown component parameters for {typeof(TComponent).FullName}: {string.Join(",", unknownKeys)}";
        logger.LogWarning(warningmsg);
        Console.WriteLine(warningmsg);
    }

    return filteredParameters;
}

This adjustment makes the code more resilient in case a non-existent parameter is sent in. Please note that an invalid parameter value will still give a runtime exception, so further validation could be added here, such as using TypeConverter to check if we can convert the parameter value sent in to the parameter in the component. The revised code is shown below.

@using System.Reflection
@using System.ComponentModel
@using Microsoft.AspNetCore.Components
@((MarkupString)RenderedHtml)

@typeparam TComponent

@code {
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public Dictionary<string, object?> Parameters { get; set; } = new();

    [Inject] IServiceProvider ServiceProvider { get; set; } = default!;
    [Inject] ILoggerFactory LoggerFactory { get; set; } = default!;

    private string RenderedHtml { get; set; } = string.Empty;

    protected override async Task OnInitializedAsync()
    {
        if (ChildContent != null)
        {
            Parameters["ChildContent"] = ChildContent;
        }

        RenderedHtml = await RenderComponentAsync(Parameters);
    }

    private async Task<string> RenderComponentAsync(Dictionary<string, object?> parameters)
    {

        if (!typeof(Microsoft.AspNetCore.Components.IComponent).IsAssignableFrom(typeof(TComponent)))
        {
            throw new InvalidOperationException($"{typeof(TComponent).Name} is not a valid Blazor component.");
        }

        using var htmlRenderer = new HtmlRenderer(ServiceProvider, LoggerFactory);

        var filteredParameters = GetValidParameters(parameters);

        var parameterView = ParameterView.FromDictionary(filteredParameters);

        var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
        {
            var result = await htmlRenderer.RenderComponentAsync(typeof(TComponent), parameterView);
            return result.ToHtmlString();
        });

        return html;
    }


    private Dictionary<string, object?> GetValidParameters(Dictionary<string, object?> parameters)
    {
        var componentType = typeof(TComponent);

        // Get valid property names and their PropertyInfo
        var validProperties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(p => p.IsDefined(typeof(ParameterAttribute), true))
            .ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);

        // Add ChildContent as a special case
        validProperties["ChildContent"] = null!;

        var filteredParameters = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);

        foreach (var kvp in parameters)
        {
            if (validProperties.ContainsKey(kvp.Key))
            {
                var propertyInfo = validProperties[kvp.Key];

                if (propertyInfo != null)
                {
                    var targetType = propertyInfo.PropertyType;
                    var value = kvp.Value;

                    // Check if value is null or already assignable
                    if (value == null || targetType.IsInstanceOfType(value))
                    {
                        filteredParameters[kvp.Key] = value;
                    }
                    else
                    {
                        // Use TypeDescriptor to check conversion
                        var converter = TypeDescriptor.GetConverter(targetType);
                        if (converter != null && converter.CanConvertFrom(value.GetType()))
                        {
                            try
                            {
                                filteredParameters[kvp.Key] = converter.ConvertFrom(value);
                            }
                            catch
                            {
                                LogConversionWarning(kvp.Key, value);
                            }
                        }
                        else
                        {
                            LogConversionWarning(kvp.Key, value);
                        }
                    }



🧪 Demo Page — Rendering Alerts Dynamically | GenericHtmlRendererDemo.razor


@page "/GenericHtmlRendererDemo"
@using BlazorIntroCourse.Components.Demos.TemplatedComponents

<h3>GenericHtmlRendererDemo - Dynamic Alert rendering</h3>

<h6>Here the parameters are set in code</h6>
<GenericHtmlRenderer TComponent="Alert"
                     ChildContent="alertContent"
                     Parameters="alternateAlertParameters" />

<h6>Here we use the ChildContent element to enter in the html</h6>
<GenericHtmlRenderer TComponent="Alert"
                     Parameters="alertParameters">
    <ChildContent>
        <text>This is the second alert</text>
    </ChildContent>                 
</GenericHtmlRenderer>

<h6>Here we use inline object initializer for parameters</h6>
<GenericHtmlRenderer TComponent="Alert"
                     Parameters="@(new Dictionary<string, object>{
    [ "AlertType" ] = AlertTypeEnum.Danger,
    [ "IsDismissable" ] = true
})">
    <ChildContent>
        <text>This is the third alert</text>
    </ChildContent>  
</GenericHtmlRenderer>

@code {

    private RenderFragment alertContent = @<text>This is the first Alert</text>;
    Dictionary<string, object?> alertParameters = new()
    {
        { "AlertType", AlertTypeEnum.Info}, 
        { "IsDismissable", true }
    };

    Dictionary<string, object?> alternateAlertParameters = new()
    {
        { "AlertType", AlertTypeEnum.Success }, 
        { "IsDismissable", true }
    };

}


📘 Diagram: Where You Can Use HtmlRenderer

HtmlRenderer API .NET 8 MVC Razor Pages Minimal APIs Background Jobs (emails, PDFs, reports) Static HTML Generation

🏁 Wrapping Up

HtmlRenderer in .NET 8 is one of those features that quietly unlocks a huge amount of flexibility. It turns Blazor components into universal UI building blocks that can be rendered:

  • in a browser
  • on the server
  • inside MVC
  • inside Razor Pages
  • inside Minimal APIs
  • inside background services
  • inside unit tests
  • or even at build time

The generic component renderer shown here is a concrete example of how powerful this can be — dynamic component rendering, runtime composition, and server‑side HTML generation all in one.

If you’re curious, you can explore the full source code here: (part of a larger repo I am looking into currently as a playground for misc Blazor functionality):
👉 https://github.com/toreaurstadboss/BlazorIntroCourse/tree/main/Components/Demos/TemplatedComponents

Sunday, 21 December 2025

Finding out file extension from byte inspection

Consider a byte array stored in a column in a table in a database column. How can we identify the file extension of the byte array by inspecting the byte array itself?
Note that byte arrays could be saved many places, also within files or similar.
The extension of a file can be discovered by inspect the File header. This is the first bytes, usually the first tens or hundreds of bytes of the byte array and constitute the file header. Some extensions got multiple file headers. A best effort to identity byte contents of a column in a database.

Let's use Powershell to inspect a file on disk, a sample JPEG file (.jpg). Lets run the following little script:

format-hex .\Stavkyrkje_Røldal.jpg | Select-Object -First 16 The first few bytes are FF D8 FF
I have added a sample Github repo with utility code to check well-known file types for their file extensions.

https://github.com/toreaurstadboss/FileHeaderUtil

The following screenshot shows the application in use. It found out that a byte array seems to be a PDF file by looking at the file header and file trailer. A good match was found :


In fact, a very good match, since both the header and the trailer fully agrees. Note that the 0A bytes are just padding bytes at the end of files and ignored in this util. See the method NormalizeHex presented further below.

Using Gary Kessler`s assembled lists of known file headers and trailers for well-known file types

The util class below shows the helper methods that inspects a byte array and evalues the file header and file trailer against a list of known such headers and trailers.

It bases a compilation of known file headers and file trailers known as "Magic Numbers", compiled by Gary Kessler during the years. In all, 600+ known file types are checked against to classify the matching file extension. Please note that there are cases where multiple matches exists of file header and file trailers matching the given byte array. The matches are sorted by number of matching bytes. The assembled list is very helpful. Thanks, Gary !

Using the file header and also possibly the last bytes of a byte array, the file trailer, we can classify the file type we have in the byte array, i.e. file extension is also implied here by recognizing the file array.

Of course, if one is allowing byte array to be uploaded from a public site for example, it still would be possible to inject malicious bytes, but being able to detect the kind of file is useful both concerning security policies and also determine if the bytes should be handled by an external application or provide information to the end-user what kind of file we have provided a path to for this util.

The curated list of file headers is based upon the list of signatures gathered by Gary Kessler and published on his website here (license of that file is not stated and considered public as it is publicly available information on his website not marked with a license):

https://www.garykessler.net/library/file_sigs.html

This list contains about 650 file types and should cover most of the wellknown formats, including formats not being used so often anymore. If you want to augment the list, check other sources such as Wikipedia if there is information about the given file extension's file header and/or file trailer, so-called "Magic number".

The curated list was updated 3rd June 2023 and contains most well-known file types.

The program uses the file signatures (Json format) to identity the file types of a byte array. Most usually, this is judged by looking at the first few bytes of the file (the so-called "magic numbers"). Sometimes, the file signature may also include bytes from the end of the file (the "trailer").


FileSignatureUtil.cs



using System;
using System.Collections.Generic;
using System.Text;

namespace FileHeaderUtil;


public static class FileSignatureUtil
{

    static FileSignature[] _fileSignatures = [];

    static FileSignatureUtil()
    {
        string json = File.ReadAllText("file_Sigs.json");
        var fileSignaturesRoot = System.Text.Json.JsonSerializer.Deserialize<FileSignatureRootElement>(json, new System.Text.Json.JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        });
        _fileSignatures = fileSignaturesRoot?.FileSigs?.ToArray()!;
    }

    /// <summary>
    /// Scans the specified file and returns a list of file signatures that match the file's header and, if applicable,
    /// file's trailer.
    /// </summary>
    /// <remarks>Only file signatures with a defined header are considered for matching. Trailer matching is
    /// performed if both the file and the signature define a trailer. A header and trailer of 64 bytes is evaluted to also 
    /// detect file types / extensions with longer headers and trailers.</remarks>
    /// <param name="targetFile">The path to the file to be analyzed. Cannot be null or empty.</param>
    /// <param name="byteCount">The number of bytes to read from the file for signature matching. Defaults to 64.</param>
    /// <param name="offset">The byte offset at which to begin reading the file for signature matching. Defaults to 0.</param>
    /// <param name="origin">Specifies the reference point used to obtain the offset. Defaults to <see cref="SeekOrigin.Begin"/>.</param>
    /// <returns>A list of <see cref="FileSignature"/> objects that match the file's header and trailer. The list is empty if no
    /// signatures match.</returns>

    public static List<FileSignature> GetMatchingFileSignatures(string targetFile, int byteCount = 64, int offset = 0, SeekOrigin origin = SeekOrigin.Begin)
    {
        static string NormalizeHex(string? hex, bool trimPadding)
        {
            if (string.IsNullOrWhiteSpace(hex))
            {
                return string.Empty;
            }           

            var parts = hex.Replace("-", " ").Split(new[] { ' ', }, StringSplitOptions.RemoveEmptyEntries)
                           .Select(h => h.ToUpperInvariant())
                           .ToList();

            if (trimPadding)
            {
                while (parts.Count > 0 && (parts.Last() == "0A" || parts.Last() == "0D" || parts.Last() == "00"))
                {
                    parts.RemoveAt(parts.Count - 1);
                }
            }

            return string.Join(" ", parts);
        }

        var matches = new List<(FileSignature Sig, int Score)>();

        string fileHeader = NormalizeHex(FileUtil.ShowHeader(targetFile, offset: 0), trimPadding: false);
        string fileTrailer = NormalizeHex(FileUtil.ShowTrailer(targetFile), trimPadding: true);

        foreach (var signature in _fileSignatures)
        {
            if (string.IsNullOrWhiteSpace(signature?.HeaderHex) || signature.HeaderHex == "(NULL)")
                continue;

            string sigHeader = NormalizeHex(signature.HeaderHex, trimPadding: false);
            string sigTrailer = NormalizeHex(signature.TrailerHex, trimPadding: true);

            if (!fileHeader.StartsWith(sigHeader, StringComparison.OrdinalIgnoreCase))
                continue;

            // Trailer check if defined
            if (!string.IsNullOrWhiteSpace(sigTrailer) && sigTrailer != "(NULL)")
            {
                if (!fileTrailer.EndsWith(sigTrailer, StringComparison.OrdinalIgnoreCase))
                    continue;
            }

            // Compute match score (# of matching bytes in header and trailer of file)
            int headerScore = CountMatchingPrefix(fileHeader, sigHeader);
            int trailerScore = CountMatchingSuffix(fileTrailer, sigTrailer);
            int scoreMeasuredAsMatchingByteCount = headerScore + trailerScore;
            signature.MatchingBytesCount = scoreMeasuredAsMatchingByteCount;
            signature.MatchingTrailerBytesCount = trailerScore;
            signature.MatchingHeaderBytesCount = headerScore;
            matches.Add((signature, scoreMeasuredAsMatchingByteCount));
        }

        return matches.OrderByDescending(m => m.Score).Select(m => m.Sig).ToList();
    }

    // Helpers
    private static int CountMatchingPrefix(string source, string pattern)
    {
        var srcParts = source.Split(' ');
        var patParts = pattern.Split(' ');
        int count = 0;
        for (int i = 0; i < Math.Min(srcParts.Length, patParts.Length); i++)
        {
            if (srcParts[i].Equals(patParts[i], StringComparison.OrdinalIgnoreCase))
                count++;
            else break;
        }
        return count;
    }

    private static int CountMatchingSuffix(string source, string pattern)
    {
        if (string.IsNullOrWhiteSpace(pattern)) return 0;
        var srcParts = source.Split(' ');
        var patParts = pattern.Split(' ');
        int count = 0;
        for (int i = 0; i < Math.Min(srcParts.Length, patParts.Length); i++)
        {
            if (srcParts[srcParts.Length - 1 - i].Equals(patParts[patParts.Length - 1 - i], StringComparison.OrdinalIgnoreCase))
                count++;
            else break;
        }
        return count;
    }

}





As we can see in the source code of NormalizeHex, ending padding chars are removed at the end, since in some cases, byte arrays (files or byte columns in databases for examples) are padded with certain bytes. Also, upper-case is applied and '-' is replaced by space ' '.

In the example below, a PDF file is scanned with the console app and the PDF file header and trailer is recognized. In this case, we also peel of trailing bytes at the end, as the specific PDF file had trailing bytes of pad bytes, more specifically : 0A.

FileUtil.cs

The util class here is used to load a file header or file trailer, a smaller byte array usually. 64 bytes is default evaluated here and should cover most file types file headers and file trailers, actually most file types only has 8 bytes or even less as a file header or file trailer.


namespace FileHeaderUtil
{

    /// <summary>
    /// Helper class for file operations
    /// </summary>
    public static class FileUtil
    {

        /// <summary>
        /// Prints the file header HEX representation
        /// </summary>
        /// <param name="filePath"></param>
        /// <param name="byteCount">Read the first n bytes. Defaults to 64 bytes.</param>
        /// <returns></returns>
        public static string? ShowHeader(string filePath, int byteCount = 64, int offset = 0)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            byte[] header = ReadBytes(filePath, byteCount, offset, SeekOrigin.Begin);
            if (header == null)
            {
                return null;
            }
            return BitConverter.ToString(header);
        }

        /// <summary>
        /// Prints the file trailer HEX representation
        /// </summary>
        /// <param name="filePath"></param>
        /// <param name="byteCount">Read the last n bytes. Defaults to 64 bytes.</param>
        /// <returns></returns>
        public static string? ShowTrailer(string filePath, int byteCount = 64, int offset = 0)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            byte[] header = ReadBytes(filePath, byteCount, offset, SeekOrigin.End);
            if (header == null)
            {
                return null;
            }
            return BitConverter.ToString(header);
        }

        /// <summary>
        /// Reads the n bytes of a byte array. Either from the start or the end of the byte array.
        /// </summary>
        /// <param name="filePath">File path of target file to read the byets</param>
        /// <param name="byteCount">The number of bytes to read</param>
        /// <param name="offset">Offset - number of bytes</param>
        /// <param name="origin">Origin to seek from. Can be either SeekOrigin.Begin, SeekOrigin.Current or SeekOrigin.End</param>
        /// <returns></returns>
        private static byte[] ReadBytes(string filePath, int byteCount, int offset = 0, SeekOrigin origin = SeekOrigin.Begin)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            if (byteCount < 1)
            {
                return Array.Empty<byte>();
            }
            byte[] buffer = new byte[byteCount];
            using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
            if (origin == SeekOrigin.Begin && offset > 0)
            {
                fileStream.Seek(offset, origin);
            }
            else if (origin == SeekOrigin.End)
            {
                fileStream.Seek(-1 * Math.Abs(offset+byteCount), origin);
            }
            else
            {
                //origin must be Current - offset is expected from the current position, just like SeekOrigin.Begin
                fileStream.Seek(offset, origin); 
            }

            int bytesRead = fileStream.Read(buffer, 0, byteCount); 
            if (bytesRead < byteCount)
            {
                Array.Resize(ref buffer, bytesRead);
            }
            return buffer;
        }        
        
    }

}


This console app will only consider max three matching file headers/trailers in cases where multiple such byte array pairs matches a given byte array of a file. To adjust this, see in Program.cs and adjust the Take parameter. Matches are ordered by number of bytes matching.

Tuesday, 9 December 2025

Enable font ligatures in Visual Studio 2026

Font ligatures are a cognitive boost for developers when reading code inside an IDE.

What are font ligatures

Font ligatures are special glyphs that combine multiple characters into a single, elegant symbol. For example, =>, ===, or != can appear as smooth connected symbols instead of separate characters. They don’t change your code—just make it more readable and visually appealing.

  • 🎨 Aesthetic boost – Makes your code look clean and modern without changing functionality.
  • 👁️ Better readability – Reduces visual clutter, making code easier on the eyes.
  • 🔍 Clearer syntax – Turns multi-character operators like => or === into neat symbols for quick recognition.
  • Faster comprehension – Helps spot patterns and logic flow at a glance.


In case you want to enable Font ligatures inside VS 2026, Visual Studio 2026, you actually have to resort to running a Powershell script or similar to alter the registry a bit.

EnableFonLigatures.ps1 | Powershell





# Enable Font Ligatures for Visual 2026 (18.x)
$basePath = "HKCU:\Software\Microsoft\VisualStudio"
$targetPrefixes = @("18.0_")
foreach ($prefix in $targetPrefixes) {
    $vsKeys = Get-ChildItem -Path $basePath | Where-Object { $_.PSChildName -like "$prefix*" }
    if ($vsKeys.Count -eq 0) {
        Write-Host "No keys found for prefix $prefix. Open Visual Studio and change Fonts & Colors once, then rerun."
    } else {
        foreach ($key in $vsKeys) {
            $fontColorsPath = Join-Path $key.PSPath "FontAndColors\Text Editor"
            
            # Create the path if missing
            if (-not (Test-Path $fontColorsPath)) {
                Write-Host "Creating missing path: $fontColorsPath"
                New-Item -Path $fontColorsPath -Force | Out-Null
            }
            # Set EnableFontLigatures to 1
            Set-ItemProperty -Path $fontColorsPath -Name "EnableFontLigatures" -Value 1 -Type DWord
            Write-Host "Ligatures enabled for: $fontColorsPath"
        }
    }
}


The following screenshot shows two ligatures symbols. Note the special symbols for => ('goes to') and != ('not equals') that are combined into one elegant symbol, which is more readable for the reader.