Skip to main content

A high-performance, single-pass, zero-allocation HTML opening tag parser for .NET 8+

Total Downloads
827
827
Published
Aug 4, 2026
3mo ago

Get Started

dotnet add package HtmlTag --version 1.1.0
ProductVersions
.NET
net8.0

README

HtmlTag

A high-performance, single-pass, zero-allocation HTML opening tag parser for .NET 8+

NuGet

Overview

HtmlTag is a lightweight, high-performance HTML tag parser optimized for parsing opening HTML tags with minimal memory allocations. It's designed for scenarios where you're already processing HTML and need to quickly extract tag names, attributes, and positional information from opening tags, while avoiding the headache of crazy HTML issues found in the wild.

Key Features

  • ⚑ High Performance - Single-pass parsing with ReadOnlySpan<char> for zero-allocation operations
  • 🎯 Precise Parsing - Extracts tag name, attributes, and positional information
  • πŸ”§ Robust - Handles malformed and "ugly" HTML that browsers accept
  • πŸ“¦ Minimal Allocations - Dictionary only allocated if attributes exist
  • πŸ”„ Reusable - Same instance can parse multiple tags
  • 🎨 Flexible API - Accepts both string and ReadOnlySpan<char> inputs

Supported HTML Patterns

HtmlTag handles a wide variety of HTML tag formats:

  • βœ… Self-closing tags: <img />, <br/>
  • βœ… Tags with quoted attributes: <div class="red">
  • βœ… Tags with unquoted attributes: <input type=text>
  • βœ… Boolean attributes: <input disabled>
  • βœ… Mixed quote styles: <div class='a' title="b">
  • βœ… Malformed but browser-accepted HTML: <div class = " red " foo =bar>
  • βœ… Complex attribute values: URLs, special characters, etc.

Installation

dotnet add package HtmlTag

Or add to your .csproj:

<PackageReference Include="HtmlTag" Version="1.0.0" />

Quick Start

Following examples assume:

using static System.Console;
using static Xunit.Assert;

Basic Usage

using DotNetXtensions.Html;

// Parse a simple tag
var tag = new HtmlTag();
bool parsed = tag.Parse("<div class='container'>");

True(parsed);
Equal("div", tag.Name);
Equal("container", tag.Attributes["class"]);

Parsing with Attributes

var tag = new HtmlTag();
bool parsed = tag.Parse("<img src='photo.jpg' alt='My Photo' width=800 />");

True(parsed);
Equal("img", tag.Name);
True(tag.IsSelfClosed);
Equal(3, tag.Attributes.Count);
Equal("photo.jpg", tag.Attributes["src"]);
Equal("My Photo", tag.Attributes["alt"]);
Equal("800", tag.Attributes["width"]);

High-Performance Span API

For zero-allocation parsing when you already have a ReadOnlySpan<char>:

ReadOnlySpan<char> html = "<div class='test'>".AsSpan();
var tag = new HtmlTag();

bool parsed = tag.Parse(html, startIndex: 0);

True(parsed);
Equal("div", tag.Name);
Equal("test", tag.Attributes["class"]);

Parsing from Within a Larger HTML Document

string html = "<html><body><div id='main'>Content</div></body></html>";
var tag = new HtmlTag();

// Find and parse the div tag starting at position 12
bool parsed = tag.Parse(html, startIndex: 12);

True(parsed);
Equal("div", tag.Name);
Equal(12, tag.StartIndex);
Equal(15, tag.Length);
Equal("main", tag.Attributes["id"]);

API Reference

Parse Methods

bool Parse(string inputHtml, int startIndex = 0, bool findTagEnd = true)

Parses an HTML opening tag from a string.

  • inputHtml: The HTML string containing the tag
  • startIndex: Zero-based index where the tag starts (at <)
  • findTagEnd: If true, searches for the closing >; if false, assumes input ends at >
  • Returns: true if parsing succeeded, false if malformed
bool Parse(ReadOnlySpan<char> htmlSpan, int startIndex = 0, bool findTagEnd = true)

High-performance overload that accepts a ReadOnlySpan<char> for zero-allocation parsing.

Properties

Property Type Description
Name string The HTML tag name (e.g., "div", "img")
Attributes Dictionary<string, string> Attribute name-value pairs (null if no attributes)
StartIndex int Zero-based index where tag starts (at <)
Length int Total length of tag including < and >
IsSelfClosed bool Whether tag ends with />
InnerStartIndex int Index one character after <
InnerLength int Length excluding < and >
NoAttributes bool Whether tag has no attributes

Examples

Boolean Attributes

Boolean attributes (no = sign) are stored with a null value, distinguishing them from attributes with an explicitly empty value (=""), which are stored as "".

var tag = new HtmlTag();
bool parsed = tag.Parse("<input type=text disabled required>");

True(parsed);
Equal("text", tag.Attributes["type"]);
Null(tag.Attributes["disabled"]);   // boolean attribute β†’ null
Null(tag.Attributes["required"]);   // boolean attribute β†’ null

// Explicitly empty value is distinct from boolean:
tag.Parse("<input title=''>");
Equal("", tag.Attributes["title"]); // explicit empty quotes β†’ ""

Complex Real-World Example

var tag = new HtmlTag();
string html = @"<img
    class='thumb-image'
    data-image='https://example.com/photo.png'
    data-image-dimensions=1920x1080
    data-image-focal-point='0.5,0.5'
    alt='My Photo'
    src='https://example.com/photo.png?format=1200w'
    data-load=false />";

bool parsed = tag.Parse(html);

True(parsed);
Equal("img", tag.Name);
True(tag.IsSelfClosed);
Equal(7, tag.Attributes.Count);
Equal("thumb-image", tag.Attributes["class"]);
Equal("https://example.com/photo.png", tag.Attributes["data-image"]);
Equal("1920x1080", tag.Attributes["data-image-dimensions"]);
Equal("0.5,0.5", tag.Attributes["data-image-focal-point"]);
Equal("My Photo", tag.Attributes["alt"]);
Equal("https://example.com/photo.png?format=1200w", tag.Attributes["src"]);
Equal("false", tag.Attributes["data-load"]);

Parsing Multiple Tags (Reusable Instance)

NOTE! This is a dubious example, as HtmlTag is designed to parse a single tag at a time, not to stream through and correctly find all tags. This is a KISS, simply type, and not a full HTML parser. In any case... this still may demonstrate reusability.

string html = "<div><p>Hello</p>  \t<img src='pic.jpg'/></div>";
var tag = new HtmlTag();
int pos = 0;
var foundTags = new List<(string name, int position)>();

while (pos < html.Length) {
    if (html[pos] == '<') {
        if (tag.Parse(html, startIndex: pos)) {
            foundTags.Add((tag.Name, tag.StartIndex));
            pos = tag.StartIndex + tag.Length;
            continue;
        }
    }
    pos++;
}

Equal(3, foundTags.Count);
Equal(("div", 0), foundTags[0]);
Equal(("p", 5), foundTags[1]);
Equal(("img", 20), foundTags[2]);

Handling Malformed HTML

HtmlTag is forgiving like browsers, handling common malformations:

var tag = new HtmlTag();

// Extra spaces around equals
bool parsed = tag.Parse("<div class  =  'test'>");
True(parsed);
Equal("test", tag.Attributes["class"]);

// Unquoted attribute values
tag.Parse("<img width=800 height=600>");
Equal("800", tag.Attributes["width"]);
Equal("600", tag.Attributes["height"]);

HtmlTokenizer β€” stream-level tokenizing

HtmlTokenizer is the stream-level companion to HtmlTag: a forgiving, forward-only reader (in the XmlReader / Utf8JsonReader style) that walks a whole string and yields a flat token sequence β€” Text | Open | Close β€” with HtmlTag doing the per-tag parsing underneath.

var reader = new HtmlTokenizer("a<b>bold</b> 5 < 7 <img src='x.png'/>");

while(reader.Read()) {
    switch(reader.Kind) {
        case HtmlTokenKind.Text:  Write(reader.TextSpan);           break; // "a", "bold", " 5 < 7 "
        case HtmlTokenKind.Open:  Use(reader.Name, reader.Tag);     break; // "b", "img" (+ attributes)
        case HtmlTokenKind.Close: CloseScope(reader.Name);          break; // "b"
    }
}

The recovery rule

The tokenizer's defining behavior: an angle-bracket run that doesn't parse as a well-formed tag is text, never an error. "5 < 7" stays one text run; the stray bracket in "a<<i>b" merges into the text; wild-caught pseudo-markup flows through intact. This is how you survive real-world HTML fragments without the HTML5 spec's 80-state tokenizer.

Policy-free by design

The tokenizer knows no vocabulary and makes no decisions for you:

  • Unknown element names are still Open tokens β€” a consumer holding an allowlist decides what counts as markup for it, and can restore anything else as literal text via RawSpan (the token's raw source slice).
  • No entity decoding, no case folding, no void-element inference (<br> is an Open like any other; check Tag.IsSelfClosed for />), no tree building β€” those belong to the consumer or a tree layer.
  • Closing tags share the opening tags' name authority (same validator), so Unicode element names tokenize symmetrically on both sides. A "closer" carrying attributes or junk (</i x>) fails and becomes text.

Capturing raw inner content

After reading a non-self-closed Open, TryReadRawInner captures the element's raw inner span up to its matching closer β€” counting same-name nesting β€” and positions the reader just past it:

var reader = new HtmlTokenizer("<i>a<i>b</i>c</i>d");

reader.Read();                                  // Open "i"
reader.TryReadRawInner(out var inner);          // "a<i>b</i>c" β€” nesting counted
reader.Read();                                  // Text "d"

Returns false (position unmoved) when no matching closer exists, so callers can fall back to streaming.

v1 scope

Built for fragment-level inline HTML (the common "strings with markup in them" case). Comments, doctype, CDATA, and raw-text elements (<script>/<style>) are not special-cased β€” they simply recover as text. Reuse contract matches HtmlTag: on an Open token, Tag is a single reused instance β€” read it before the next Read().

Performance Characteristics

Memory Allocations

  • Zero allocations for tag and attribute name parsing (uses spans)
  • Minimal allocations: Only for final tag name and attribute values
  • Dictionary only allocated if attributes exist - tags without attributes have zero dictionary overhead
  • Reusable instances - Parse multiple tags with the same HtmlTag object

Parsing Speed

  • Single-pass parsing - Scans input once
  • Aggressive inlining on hot paths
  • SIMD-optimized IndexOf operations via ReadOnlySpan<char>
  • Fast-path validation for common ASCII tag/attribute names

Edge Cases and Behavior

Duplicate Attributes

When duplicate attribute names exist, the first one wins (matching browser behavior):

var tag = new HtmlTag();
bool parsed = tag.Parse("<div class='first' class='second'>");

True(parsed);
Equal("first", tag.Attributes["class"]); // First one wins

Empty Attribute Values

var tag = new HtmlTag();
bool parsed = tag.Parse("<div title='' data-test>");

True(parsed);
Equal("", tag.Attributes["title"]);
Null(tag.Attributes["data-test"]);
Equal(2, tag.Attributes.Count);

Special Characters in Attributes

var tag = new HtmlTag();
bool parsed = tag.Parse(@"<div data-json='{""key"":""value""}'>");

True(parsed);
// Attributes are NOT unescaped - returned as-is
Equal(@"{""key"":""value""}", tag.Attributes["data-json"]);

Limitations

  • ❌ Only parses opening tags - Does not handle closing tags or tag content
  • ❌ Does not unescape HTML entities - Attribute values returned as-is
  • ❌ No validation of tag or attribute names against HTML spec, but does validate according to XML-valid names
  • ❌ No namespace support - Treats namespaces as part of the tag name

Requirements

  • .NET 8.0 or higher
  • C# 12.0 or higher

Related Projects

HtmlTag differs by being laser-focused on fast, single-tag parsing with minimal overhead, rather than full document parsing.

Benchmarks

Coming soon hopefully.

Downloads by Version

Similar & Alternative Packages

Hand-picked matching NuGet libraries based on tags & ecosystem

Depended On By

Find packages that declare this package as a dependency. NuGet does not provide a reverse-dependency endpoint, so candidates are checked on demand.

Adoption Assessment

Assess this package against its supported frameworks, license, maintenance signals and dependency graph before making it a project standard.

Compatibility metadata lists 1 target framework; confirm the exact target used by your application.

Related Guides & Workflows

Learn how to evaluate, integrate, or audit this package in your .NET project.