Install onehtml for .NET 10, parse an HTML page the way a browser does, query it with CSS selectors, make an edit that leaves every other byte alone, compute the accessibility tree, and check the page against WCAG 2.2 AA with SARIF output.
Install
onehtml targets net10.0, so you need the .NET 10 SDK. The three packages share one version; this guide uses 1.0.0.
dotnet new console -o OneHtmlStart cd OneHtmlStart dotnet add package RecursiveAI.OneHtml --version 1.0.0 dotnet add package RecursiveAI.OneHtml.Accessibility --version 1.0.0 dotnet add package RecursiveAI.OneHtml.Accessibility.Validation --version 1.0.0
Install only what you use: RecursiveAI.OneHtml.Accessibility brings in RecursiveAI.OneHtml, and RecursiveAI.OneHtml.Accessibility.Validation brings in both. The libraries use nothing outside the .NET base class library, contain no unsafe code, and work with NativeAOT and trimming.
The libraries never read files or the network. You load the bytes and hand them over, as the examples below do. Scripts are kept as inert text, and nothing is laid out or painted.
The sample page
Every step below runs against events.html, a 2,051-byte events page for a made-up library. It has a header with a logo and navigation, two event cards, a newsletter form and a footer. It also has two problems an accessibility check should catch: an image with no alt and an icon-only submit button.
<article class="card" data-event-id="114"> <h2>Story time for ages 3–5</h2> <p>Saturdays at 10:30 in the children’s room. <a href="/events/story-time/">Details</a></p> <img src="/img/story-time.jpg" width=640 height=360> </article>
The snippets are one program, in order. Put these using directives at the top of Program.cs:
using RecursiveAI.OneHtml; using RecursiveAI.OneHtml.Editing; using RecursiveAI.OneHtml.Css; using RecursiveAI.OneHtml.Accessibility; using RecursiveAI.OneHtml.Accessibility.Validation; using RecursiveAI.OneHtml.Accessibility.Validation.Catalogue;
Parse a document
Document.Parse takes bytes, a string or a SourceText. Give it bytes when you have them: it then picks the encoding the way a browser does (byte order mark, then the Content-Type you pass in ParseOptions, then the <meta> prescan) and records how sure it is.
byte[] bytes = File.ReadAllBytes("events.html"); Document doc = Document.Parse(bytes); Console.WriteLine($"Encoding: {doc.Source.Encoding.Name} ({doc.Source.Confidence})"); Console.WriteLine($"Mode: {doc.Mode}, nodes: {doc.NodeCount}");
Encoding: UTF-8 (Certain) Mode: NoQuirks, nodes: 101
Parsing follows the WHATWG HTML standard: the tokenizer, every insertion mode, foster parenting, templates and foreign content. Malformed markup doesn’t throw. It becomes the same tree a browser would build. A Document is an immutable snapshot that any number of threads can read at once, and Node is a small struct handle into it.
Query with CSS selectors
QuerySelector returns the first match, or a null node (check IsNull). QuerySelectorAll returns every match in document order. Both take Selectors Level 4, including :has(), :is(), :where() and :not() with selector lists.
Node title = doc.Root.QuerySelector("title"); Console.WriteLine(title.TextContent); foreach (Node heading in doc.Root.QuerySelectorAll("article.card > h2")) { Node article = heading.Closest("article"); Console.WriteLine($"{article.GetAttribute("data-event-id")}: {heading.TextContent}"); } Node missing = doc.Root.QuerySelector("main img:not([alt])"); Console.WriteLine($"No alt: {missing.GetAttribute("src")}, has width: {missing.HasAttribute("width")}");
Events | Harbor Street Library 114: Story time for ages 3–5 115: Repair café No alt: /img/story-time.jpg, has width: True
Character references are already decoded: – reads as – and café as café. GetAttribute returns null when the attribute is absent and "" when it is present but empty.
Make a lossless edit and write it back
doc.Apply(edits) takes a list of edits aimed at nodes of the snapshot. Either all of them apply or none do. The output is spliced from the original bytes: only the bytes an edit touches change. The engine re-parses the result and checks that it matches the expected tree before returning it. The old snapshot and its handles stay valid.
EditResult result = doc.Apply(new[] { Edit.SetAttribute(missing, "alt", "Children listening to a story"), }); if (!result.Succeeded) { throw new InvalidOperationException(result.Failure); } byte[] output = result.Document!.ToLosslessBytes(); File.WriteAllBytes("events.edited.html", output); EditedRange range = result.Changes!.Ranges.Single(); Console.WriteLine($"Inserted at byte {range.OldByteRange.Start}: [{range.NewText}]"); int at = range.OldByteRange.Start; int grew = output.Length - bytes.Length; bool headSame = bytes.AsSpan(0, at).SequenceEqual(output.AsSpan(0, at)); bool tailSame = bytes.AsSpan(range.OldByteRange.End).SequenceEqual(output.AsSpan(range.OldByteRange.End + grew)); Console.WriteLine($"{bytes.Length} -> {output.Length} bytes; before the edit identical: {headSame}, after: {tailSame}");
Inserted at byte 1086: [ alt="Children listening to a story"] 2051 -> 2087 bytes; before the edit identical: True, after: True
The unquoted width=640 height=360, the – reference, the comments and the indentation all stay as they were. diff shows one changed line:
33c33 < <img src="/img/story-time.jpg" width=640 height=360> --- > <img src="/img/story-time.jpg" width=640 height=360 alt="Children listening to a story">
The other operations are RemoveAttribute, ReplaceText, InsertBefore, InsertAfter, Prepend, Append, Wrap, Unwrap, Replace, Remove, SetStyle and RemoveStyle. Edit HTML without reformatting it shows several at once.
Compute the accessibility tree
The accessibility tree is what a browser gives to screen readers: a role, an accessible name and states for each node that matters. AccessibilityTree.Build computes it from the document and its cascade (WAI-ARIA 1.2, HTML-AAM and AccName 1.2), using Chromium’s choices where the specifications leave room. No browser is involved.
DocumentStyles styles = DocumentStyles.Create(doc); AccessibilityTree tree = AccessibilityTree.Build(doc, styles); string[] interesting = { "heading", "link", "button", "image", "textbox" }; foreach (AxNode node in tree.Nodes.Where(n => !n.IsIgnored && interesting.Contains(n.Role))) { Console.WriteLine($"{node.Role,-8} \"{node.Name.Value}\" from {node.Name.Source}"); }
link "Harbor Street Library" from Contents image "Harbor Street Library" from NativeAttribute link "Books" from Contents link "Events" from Contents link "Visit" from Contents heading "Upcoming events" from Contents heading "Story time for ages 3–5" from Contents link "Details" from Contents image "" from None heading "Repair café" from Contents link "Partner site" from Contents image "Volunteers at a workbench fixing a lamp" from NativeAttribute textbox "Your email" from Placeholder button "" from None link "[email protected]" from Contents button "Back to top" from Contents
This tree was built from the original doc, so the story-time image still has no name. The submit button has none either: its only content is an aria-hidden icon. The logo link takes its name from the alt of the image inside it. The email field is named only by its placeholder.
Check the page against WCAG 2.2 AA
Checker runs one rule catalogue that covers axe-core’s WCAG and best-practice rules and every W3C-approved ACT rule. Each rule is labeled with the WCAG criteria it checks. A profile picks the rules for a standard: Wcag20AA, Wcag21AA, Wcag22AA, or All, which adds best practices.
var options = new CheckOptions { Profile = ValidationProfile.Wcag22AA }; CheckReport report = new Checker().Check(doc, options); foreach (RuleResult failure in report.WithOutcome(Outcome.Failed)) { Console.WriteLine($"{failure.RuleId} at {failure.Locator}: {failure.Message}"); } Console.WriteLine($"Failed {report.Count(Outcome.Failed)}, needs review {report.Count(Outcome.NeedsReview)}, " + $"needs render {report.Count(Outcome.NeedsRender)}, passed {report.Count(Outcome.Passed)}"); string sarif = report.ToSarifJson(new ExportOptions { Url = "events.html", Indented = true }); File.WriteAllText("events.sarif", sarif);
button-name at form > button: The button has no accessible name. image-alt at #content > article:nth-of-type(1) > img: The image has no accessible name. Failed 2, needs review 18, needs render 10, passed 62
Every result has one of five outcomes:
Failed,PassedandInapplicableare decided.NeedsReviewmeans markup and CSS can’t settle it, for example whether a heading describes its section. A person has to look.NeedsRendermeans the rule needs layout captured from a browser. Here all 10 aretarget-size, which needs the border boxes of each target and its neighbors. You can supply those asRenderedFacts(see Rendered facts), andFactsMissingon each result lists what is needed.
The checker doesn’t replace human review. It reports what it can’t decide instead of guessing.
events.sarif is SARIF 2.1.0. Each failure has its rule, its line and column, its byte offset and a source snippet. The rule’s entry in the file lists the WCAG criteria it checks.
{ "ruleId": "button-name", "ruleIndex": 28, "kind": "fail", "level": "error", "rank": 90, "message": { "text": "The button has no accessible name." }, "locations": [ { "physicalLocation": { "artifactLocation": { "uri": "events.html", "index": 0 }, "region": { "startLine": 45, "startColumn": 7, "endLine": 45, "endColumn": 29, "byteOffset": 1584, "byteLength": 22, "snippet": { "text": "<button type=\"submit\">" } } } } ] }
That is an excerpt of one result. The full result also has a logical location, a fingerprint and properties such as outcome and locator. Review results have kind: "review" and level: "none", so filter on kind as well as level. SARIF export gives the full mapping.
The packages
| Package | What it gives you |
|---|---|
RecursiveAI.OneHtml | The WHATWG HTML parser with encoding sniffing, the DOM as immutable snapshots with a byte-level source map, Selectors Level 4, unique locators, a CSS parser, the cascade under a StyleContext (viewport, media features, focus, hover), and lossless editing of HTML and CSS with a JSON edit format you can save and replay. |
RecursiveAI.OneHtml.Accessibility | The accessibility tree (WAI-ARIA 1.2, HTML-AAM, AccName 1.2, Core-AAM, SVG-AAM) with each role, name and hidden state traced to its source, incremental updates after edits, a reading view, navigation lists, tree diffs, export in Chrome’s AX-tree shape, and RenderedFacts from a Chrome DOMSnapshot you supply. |
RecursiveAI.OneHtml.Accessibility.Validation | The rule catalogue labeled by WCAG version, level and criterion; WCAG 2.0, 2.1 and 2.2 AA profiles; incremental re-checks after an edit; suggested edits where exactly one fix is correct; and native JSON, axe-compatible JSON and SARIF 2.1.0 output. |
All three are licensed MIT OR Apache-2.0, at your option.
Where next
Articles: Parse HTML in C#, Migrate from HtmlAgilityPack, HtmlAgilityPack vs AngleSharp.
- Recipes: parse HTML from a string, file or stream, CSS selectors, extract visible text, compute styles, WCAG checks in CI with SARIF and apply suggested fixes.
- How editing works, including fallback when a splice would change how the page parses: Editing.
- The selector support and its known gaps: Selectors.
- The cascade and computed values: Cascade.
- How the tree is built and where Chromium’s choices are used: Accessibility tree and Chromium profile.
- Rule selection, private rules and re-checking: The checker. Coverage per WCAG criterion: Coverage.
Guides: Web scraping in C# and CSS selectors in C#.