Change attributes, text and inline styles in an HTML file so that only the edited bytes change: quoting, character references, comments, whitespace and the rest of the file stay exactly as they were.
Most HTML libraries edit a tree and then serialize the whole tree again, which rewrites quoting, entities and whitespace across the file. onehtml splices the original bytes instead. Each edit marks the smallest region it touches, such as one attribute value or one text node, and everything outside those regions is copied unchanged. The result is then re-parsed and compared with the expected tree before it’s returned, so an edit can’t silently change how the rest of the page parses.
using System.Text; using RecursiveAI.OneHtml; using RecursiveAI.OneHtml.Editing; byte[] input = File.ReadAllBytes("events.html"); Document doc = Document.Parse(input); // With no edits, the output is the input, byte for byte Console.WriteLine($"Round trip identical: {doc.ToLosslessBytes().AsSpan().SequenceEqual(input)}"); Node partner = doc.Root.QuerySelector("a[target=_blank]"); Node visit = doc.Root.QuerySelector("a[href='/visit/']"); Node heading = doc.Root.QuerySelector("[data-event-id='115'] h2"); Node signup = doc.Root.QuerySelector("form.signup"); EditResult result = doc.Apply(new[] { Edit.SetAttribute(partner, "rel", "noopener"), Edit.SetAttribute(visit, "href", "/visit/hours/"), Edit.ReplaceText(heading, "Repair café & swap"), Edit.SetStyle(signup, "margin-top", "2rem"), }); if (!result.Succeeded) { foreach (EditOutcome outcome in result.Outcomes.Where(o => o.Status != EditStatus.Applied)) { Console.WriteLine($"{outcome.Edit}: {outcome.Status} {outcome.Reason}"); } return; } byte[] output = result.Document!.ToLosslessBytes(); foreach (EditedRange range in result.Changes!.Ranges) { string before = Encoding.UTF8.GetString(input, range.OldByteRange.Start, range.OldByteRange.Length); Console.WriteLine($"[{before}] -> [{range.NewText}]"); } // Rebuild the output from the input and the edited ranges alone var rebuilt = new List<byte>(); int position = 0; foreach (EditedRange range in result.Changes.Ranges.OrderBy(r => r.OldByteRange.Start)) { rebuilt.AddRange(input[position..range.OldByteRange.Start]); rebuilt.AddRange(range.NewBytes.ToArray()); position = range.OldByteRange.End; } rebuilt.AddRange(input[position..]); Console.WriteLine($"Everything outside the ranges is unchanged: {rebuilt.SequenceEqual(output)}"); // The old snapshot is untouched and its handles still work Console.WriteLine($"Old heading: {heading.TextContent}"); Console.WriteLine($"New heading: {result.Changes.MapToNew(heading).TextContent}"); File.WriteAllBytes("events.edited.html", output);
Round trip identical: True [/visit/] -> [/visit/hours/] [Repair café] -> [Repair café & swap] [] -> [ rel="noopener"] [] -> [ style="margin-top: 2rem;"] Everything outside the ranges is unchanged: True Old heading: Repair café New heading: Repair café & swap
The diff of the two files:
21c21 < <li><a href='/visit/'>Visit</a></li> --- > <li><a href='/visit/hours/'>Visit</a></li> 37c37 < <h2>Repair café</h2> --- > <h2>Repair café & swap</h2> 39c39 < <a href="https://example.org/repair-cafe" target="_blank">Partner site</a></p> --- > <a href="https://example.org/repair-cafe" target="_blank" rel="noopener">Partner site</a></p> 43c43 < <form action="/newsletter" method="post" class="signup"> --- > <form action="/newsletter" method="post" class="signup" style="margin-top: 2rem;">
What to expect:
- Only the edited range changes. The single-quoted
href='/visit/'stays single-quoted, because only its value was replaced. The–and’references elsewhere are untouched. Text you write is escaped for its context, which is why&became&. The replaced text’s ownéis written asé, because the whole text node was replaced. - New markup follows the file’s style. Inserted attributes and elements copy the quote character, tag and attribute case, void-element form, newline sequence and indentation the file already uses.
- The encoding is kept. Output is written in the document’s encoding. A character it can’t represent becomes a character reference in text and attribute values.
- All or nothing. If any edit can’t be applied, nothing is, and
Outcomessays why for each edit:Invalid,FailedorNotAttempted. - Snapshots are immutable.
docand every handle into it still describe the old page.Changes.MapToNewfinds a node’s counterpart in the new snapshot, andChanges.Added,RemovedandChangedlist what moved.
When a splice can’t express the edit, for example an edit that would change how the surrounding markup parses, the engine widens the region to the nearest enclosing element and rewrites just that element. Such ranges are marked IsFallback. Editing has the full rules.
For several edits built up over time, new DocumentEditor(doc) collects them and Apply() runs them as one transaction. To store edits and apply them to another copy of the page later, see Save edits as JSON and replay them. Stylesheets are edited the same way: Parse and edit CSS.