Sunday, 15 February 2026

Blazor Property Grid - Updated version

Blazor Property Grid

I wrote a Blazor Property Grid component back in 2021, the updated the version is now available some five years later.

You will find the property grid available on Nuget here:

https://www.nuget.org/packages/BlazorPropertyGridComponents/1.2.4

The property grid allows inspection of an object's properties and also edit them. Supported data types are the fundamental data types, which means integers, date times, booleans, strings and numbers.

The property grid supports nested properties, properties that are compound objects themselves. I have not yet added template supported for custom data types, but fundamentals nested properties inside the complex property are shown. This means you can drill down into an object and both inspect the object and also edit the properties that are the mentioned fundamental data types.

This project delivers a Blazor property‑grid component capable of inspecting and editing both top‑level and deeply nested properties of an object. It works smoothly with nested structures and internal members.

It has been verified using Blazor WebAssembly running on .NET. 10. A sample client is included in the BlazorSampleClient project.

The component implementation is located inside the Razor Class Library BlazorPropertyGridComponents.

Licensing is MIT and the component is provided as‑is. If used in production, you are responsible for validating its suitability. Forks, modifications, and commercial reuse are allowed. The project originated as a personal learning exercise.

The screenshot below (from the sample client) illustrates the property grid on the right side updating the same model that the form on the left is bound to.

The component expects your project to include Bootstrap and Font Awesome. You can inspect exact versions inside libman.json. Additional styling resides in styles.css.

libman.json example

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "libraries": [
    {
      "library": "bootstrap@5.3.3",
      "destination": "wwwroot/bootstrap/"
    },
    {
      "library": "font-awesome@6.5.1",
      "destination": "wwwroot/font-awesome/"
    }
  ]
}

Supported Property Types

  • DateTime (full or date‑only)
  • Float
  • Decimal
  • Int
  • Double
  • String
  • Bool
  • Enums (rendered using <select> and <option>)

Using the Component – API

Below is a Razor example showing how to use the component. The PropertySetValueCallback is optional and only required if you want UI changes reflected elsewhere immediately.

<div class="col-md-6"> <!-- Property grid shown in the right column -->
  <h4 class="mb-3">Property Grid Component Demo</h4>

  <PropertyGridComponent
      PropertySetValueCallback="OnPropertyValueSet"
      ObjectTitle="Property grid - Edit form for : 'Customer Details'"
      DataContext="@exampleModel">
  </PropertyGridComponent>
</div>

@code {
  private void OnPropertyValueSet(PropertyChangedInfoNotificationInfoPayload pi)
  {
    if (pi != null)
    {
      JsRunTime.InvokeVoidAsync(
        "updateEditableField",
        pi.FieldName,
        pi.FullPropertyPath,
        pi.Value
      );
    }
  }

  private CustomerModel exampleModel = new CustomerModel
  {
    Address = new AddressInfo
    {
      Zipcode = 7045,
      AddressDetails = new AddressInfoDetails
      {
        Box = "PO Box 123"
      }
    }
  };

  private void HandleValidSubmit()
  {
  }
}

The JavaScript function updateEditableField is located in script.js.

Updating via C# Instead of JS

You can also update the model directly through reflection. This approach ensures proper Blazor re‑rendering. Be sure to call StateHasChanged(). Note that this code is only required in case you use the property grid for editing properties and show the object you edit other places on the same page.

@code {

  private void OnPropertyValueSet(PropertyChangedInfoNotificationInfoPayload pi)
  {
    if (pi == null)
      return;

    SetPropertyByPath(exampleModel, pi.FullPropertyPath, pi.Value, pi.ValueType);
    StateHasChanged();
  }

  private void SetPropertyByPath(object target, string propertyPath, object value, string valueType)
  {
    if (target == null || string.IsNullOrEmpty(propertyPath))
      return;

    var parts = propertyPath.Split('.');
    var current = target;

    // Move to parent object

        // Navigate to the parent object
        for (int i = 0; i < parts.Length - 1; i++)
        {
            var prop = current.GetType().GetProperty(parts[i]);
            if (prop == null) return;
            current = prop.GetValue(current);
            if (current == null) return;
        }

        // Set the final property
        var finalProp = current.GetType().GetProperty(parts[^1]);
        if (finalProp == null) return;

        try
        {
            object convertedValue = value;

            if (finalProp.PropertyType.IsEnum && value != null)
            {
                var valStr = value.ToString();
                if (int.TryParse(valStr, out int intVal))
                    convertedValue = Enum.ToObject(finalProp.PropertyType, intVal);
                else
                    convertedValue = Enum.Parse(finalProp.PropertyType, valStr, ignoreCase: true);
            }
            else if (finalProp.PropertyType == typeof(bool) && value != null)
            {
                convertedValue = Convert.ToBoolean(value);
            }
            else if (value != null && finalProp.PropertyType != typeof(string))
            {
                convertedValue = Convert.ChangeType(value, finalProp.PropertyType);
            }

            finalProp.SetValue(current, convertedValue);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Failed to set {propertyPath}: {ex.Message}");
        }
    }

    private CustomerModel exampleModel = new CustomerModel
    {
        Address = new AddressInfo
        {
            Zipcode = 7045,
            AddressDetails = new AddressInfoDetails
            {
                Box = "PO Box 123"
            }
        }
    };

    private void HandleValidSubmit()
    {

    }

}

I have used Claude Haiku 4.5 LLM to create a nice Architecture Documentation of my component so it is more convenient to see the structure of the Blazor property grid component. This is handy for those developers who wants to work on the component and add features and understood its structure. As mentioned before, the component is licensed with MIT license and you can adjust the component as needed free of use (and responsiblity).

πŸ”· Blazor Property Grid Component - Architecture Documentation


πŸ“Š Property Grid Component Structure

PROPERTY GRID COMPONENT ARCHITECTURE
====================================

ROOT CONTAINER: PropertyGridComponent.razor
├── EditForm (wraps the entire grid)
└── .property-grid-container
    ├── Header Table (.property-grid-header-table)
    │   └── thead (.property-grid-header)
    │       └── tr
    │           ├── th: "Property"
    │           ├── th: "Value"
    │           └── th: Edit Button (pencil icon)
    │
    └── Body Table (.property-grid-body-table)
        └── tbody (.property-grid-body)
            └── foreach KeyValuePair in Props
                ├── IF: Simple Property (IsClass = false)
                │   └── [COMMENTED OUT - NOT DISPLAYED]
                │
                └── IF: Nested Class (IsClass = true)
                    └── tr
                        ├── td (colspan=2)
                        │   ├── Expand/Collapse Button (minus icon)
                        │   └── div (.collapse .show)
                        │       └── PropertyRowComponent [Depth=1]
                        └── td (empty)


PROPERTY ROW COMPONENT: PropertyRowComponent.razor
====================================================

FOR EACH SubProperty in PropertyInfoAtLevel.SubProperties:
├── IF: Simple Type (not a class or System namespace)
│   └── tr (.property-row)
│       ├── td (.property-name-cell)
│       │   └── span (.property-name) = Property Name
│       │
│       └── td (.property-value-cell)
│           ├── IF: DateTime
│           │   └── InputDate (if editable) OR span (if readonly)
│           │
│           ├── IF: bool
│           │   └── InputCheckbox (if editable) OR span (if readonly)
│           │
│           ├── IF: int
│           │   └── InputNumber (if editable) OR span (if readonly)
│           │
│           ├── IF: double
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: decimal
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: float
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: string
│           │   └── InputText type="text" (if editable) OR span (if readonly)
│           │
│           ├── IF: Enum
│           │   └── select (if editable)
│           │       └── option foreach Enum value
│           │       OR span (if readonly)
│           │
│           └── ELSE: Unknown Type
│               └── span = Raw Value
│
└── IF: Nested Class (PropertyValue is HierarchicalPropertyInfo)
    └── tr (.property-row .nested-property-row)
        ├── td (colspan=2, .nested-property-cell)
        │   ├── span (.nested-property-name) = Nested Class Name
        │   ├── Expand/Collapse Button (plus icon)
        │   └── div (.collapse or .collapse.show)
        │       └── PropertyRowComponent [Depth+1] (RECURSIVE)
        └── [Empty td]


DATA STRUCTURE: HierarchicalPropertyInfo
================================================

HierarchicalPropertyInfo
├── PropertyName: string
├── PropertyValue: object
├── PropertyType: Type
├── SubProperties: Dictionary<string, HierarchicalPropertyInfo>
├── FullPropertyPath: string (dot-separated path)
├── IsClass: bool (indicates if this is a class type)
├── IsEditable: bool
├── NewValue: object (for tracking changes)
└── ValueSetCallback: EventCallback

HIERARCHY BUILD PROCESS:
========================

MapPropertiesOfDataContext(root object)
├── Create ROOT HierarchicalPropertyInfo
├── For each Public Property:
│   ├── IF: Simple Type (not class or not System namespace)
│   │   └── Add to SubProperties as leaf node (IsClass=false)
│   │
│   └── IF: Nested Class (class type, not System namespace)
│       └── Recursively call MapPropertiesOfDataContext
│           └── Add to SubProperties with nested tree (IsClass=true)
│
└── Return complete tree structure


INTERACTIVITY:
==============

Edit Mode Toggle:
├── ToggleEditButton() → IsEditingAllowed = !IsEditingAllowed
└── SetEditFlagRecursive() → walks entire tree setting IsEditable on all nodes

Value Changes:
├── SetValue() called on input change
├── Handles type conversion (enums, numbers, dates, etc.)
├── Updates PropertyValue immediately for UI reflection
└── Invokes ValueSetCallback → OnValueSetCallback()

Property Change Callback:
└── PropertySetValueCallback emits PropertyChangedInfoNotificationInfoPayload with:
    ├── FieldName
    ├── FullPropertyPath
    ├── Value
    └── ValueType (text, boolean, number, date, enum)

Expand/Collapse:
└── ToggleExpandButton() → JavaScript: blazorPropertyGrid.toggleExpandButton()
    └── Toggles Bootstrap collapse class on nested div

πŸ”§ Component Descriptions and Roles

1. PropertyGridComponent (PropertyGridComponent.razor + PropertyGridComponent.razor.cs)

Role: Root container and orchestrator for the entire property grid UI

Primary Responsibility:

Accepts a data object and transforms it into a hierarchical property structure

Key Functions:

  • OnParametersSet() - Initializes the component when parameters are passed
  • MapPropertiesOfDataContext() - Recursively walks the object graph and builds the HierarchicalPropertyInfo tree
  • IsNestedProperty() - Determines if a property should be expanded (nested classes)
  • ToggleEditButton() - Handles the edit mode button click (pencil icon in header)
  • SetEditFlag() / SetEditFlagRecursive() - Propagates the IsEditable flag through the entire tree
  • OnValueSetCallback() - Listens for value changes and emits PropertySetValueCallback events to the parent component

Parameters:

  • DataContext (object) - The root object to display
  • ObjectTitle (string) - Display title for the grid
  • IsEditingAllowed (bool) - Whether fields are editable
  • PropertySetValueCallback (EventCallback) - Callback when a property value changes

Rendering:

Header table with Property/Value columns and edit button. Body table containing rows for top-level properties (delegates nested properties to PropertyRowComponent)

2. PropertyRowComponent (PropertyRowComponent.razor + PropertyRowComponent.razor.cs)

Role: Recursive component that renders individual property rows and handles nested object expansion

Primary Responsibility:

Display a single level of properties and recursively display nested levels

Key Functions:

  • SetValue() - Handles input change events, performs type conversion, and invokes callbacks
  • ToggleExpandButton() - JS interop to toggle Bootstrap collapse classes for expand/collapse UI
  • Property type detection - Conditionally renders different input controls based on property type

Type Support:

  • DateTime → InputDate (HTML5 datetime-local)
  • bool → InputCheckbox
  • int → InputNumber
  • double, decimal, float → InputText with type="number"
  • string → InputText with type="text"
  • Enum → HTML select dropdown with enum values
  • Nested Classes → Recursive PropertyRowComponent call with Depth+1
  • Unknown Types → Raw span display

Parameters:

  • PropertyInfoAtLevel (HierarchicalPropertyInfo) - The current property node to render
  • Depth (int) - Nesting depth for styling and collapse behavior
  • DisplayedFullPropertyPaths (List<string>) - Tracks which paths have been rendered (prevents duplication)

Features:

  • Editable/Read-only modes based on IsEditable flag
  • Collapse/expand functionality for nested objects
  • Visual styling (beige background for read-only values)
  • Full property path as tooltip for clarity

3. HierarchicalPropertyInfo (HierarchicalPropertyInfo.cs)

Role: Data structure representing a single property node in the object hierarchy

Primary Responsibility:

Act as a node in a tree structure that mirrors the original object's property graph

Properties:

  • PropertyName (string) - Name of the property
  • PropertyValue (object) - Current value of the property
  • PropertyType (Type) - CLR type of the property
  • SubProperties (Dictionary<string, HierarchicalPropertyInfo>) - Child properties (for nested objects)
  • FullPropertyPath (string) - Dot-separated path from root (e.g., "Customer.Address.Street")
  • IsClass (bool) - Whether this represents a class type (true = nested object, false = leaf value)
  • IsEditable (bool) - Whether the property can be edited in the current UI state
  • NewValue (object) - Tracks modified value before submission
  • ValueSetCallback (EventCallback) - Callback when this property's value changes

4. PropertyChangedInfoNotificationInfoPayload (PropertyChangedInfoNotificationInfoPayload.cs)

Role: Payload object that carries change notification information back to the parent

Primary Responsibility:

Communicate property change events with detailed context

Properties:

  • FieldName (string) - Name of the property
  • FullPropertyPath (string) - Complete path to the property
  • Value (object) - New value
  • ValueType (string) - Type of value ("text", "number", "boolean", "date", "enum")

πŸ”„ Component Interaction Flow

User interacts with PropertyGrid
    ↓
PropertyGridComponent receives DataContext
    ↓
MapPropertiesOfDataContext() builds tree of HierarchicalPropertyInfo
    ↓
Renders PropertyRowComponent for each top-level property
    ↓
PropertyRowComponent renders based on type:
  ├─ Simple types → InputControl (text, number, checkbox, date, select)
  └─ Nested classes → Recursive PropertyRowComponent
    ↓
User edits a value
    ↓
PropertyRowComponent.SetValue() processes input
    ↓
ValueSetCallback invoked
    ↓
OnValueSetCallback() determines value type and emits PropertySetValueCallback
    ↓
Parent component receives PropertyChangedInfoNotificationInfoPayload

🎯 Key Design Patterns

1. Recursive Composition

PropertyRowComponent calls itself recursively for nested objects, allowing unlimited nesting depth.

2. Tree Structure

HierarchicalPropertyInfo forms a tree that mirrors the object graph, enabling efficient traversal and state management.

3. Event Cascading

Value changes propagate up through callbacks, maintaining separation of concerns between components.

4. Type-Driven Rendering

PropertyRowComponent dynamically renders different input controls based on CLR type, supporting datetime, enum, numeric, boolean, and string types.

5. Bootstrap Collapse Integration

Nested objects use Bootstrap's collapse classes for expand/collapse functionality, toggled via JavaScript interop.


πŸ“Š Data Flow Summary

DataContext (Object)
    ↓
MapPropertiesOfDataContext() [Reflection-based tree building]
    ↓
HierarchicalPropertyInfo Tree
    ↓
PropertyGridComponent → PropertyRowComponent Chain [Rendering]
    ↓
HTML Tables + Form Controls
    ↓
[User edits value]
    ↓
ValueSetCallback Events [Bubbling up]
    ↓
PropertyChangedInfoNotificationInfoPayload [Event payload]
    ↓
Parent Component [Handles business logic]

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.