Store edits in the versioned onehtml-edits JSON format and apply them to another copy of the page later. Replay finds each target again or reports why it can’t, and never edits the wrong element.
An Edit in memory points at a node of one snapshot. To keep edits across processes or page versions, EditJson.Create turns them into an envelope whose targets are durable. Each target has a locator (a CSS selector unique in the page), a fingerprint (tag, key attributes and hashes of the text and path), and an anchor (the byte range and a hash of its bytes).
using RecursiveAI.OneHtml; using RecursiveAI.OneHtml.Editing; string html = File.ReadAllText("events.html"); Document original = Document.Parse(html); Node img = original.Root.QuerySelector("main img:not([alt])"); Node partner = original.Root.QuerySelector("a[target=_blank]"); // Save edits as versioned JSON, with durable targets instead of in-memory handles EditEnvelope envelope = EditJson.Create(original, new[] { Edit.SetAttribute(img, "alt", "Children listening to a story") .WithMeta(new Dictionary<string, object> { ["ticket"] = "WEB-212" }), Edit.SetAttribute(partner, "rel", "noopener"), }); File.WriteAllText("events.edits.json", EditJson.ToJson(envelope, indented: true)); Console.WriteLine($"First target: {envelope.Edits[0].Target!.Locator}"); void Replay(string label, string page) { EditEnvelope saved = EditJson.Parse(File.ReadAllText("events.edits.json")); ReplayResult replay = EditReplay.Replay(saved, Document.Parse(page)); Console.WriteLine($"{label}: succeeded {replay.Succeeded}"); foreach (ReplayOutcome outcome in replay.Outcomes) { Console.WriteLine($" edit {outcome.Index}: {outcome.Status} {outcome.Reason} {outcome.MetaJson}"); } } // 1. Someone changed the text above the image: every byte offset after it moved Replay("Text changed", html.Replace("Saturdays at 10:30", "Saturdays at 11:00, starting in May")); // 2. A new card was inserted first: article:nth-of-type(1) is now a different element Replay("Card inserted", html.Replace( "<!-- events are edited by hand", "<article class=\"card\"><h2>Book sale</h2><img src=\"/img/sale.jpg\" alt=\"\"></article>\n <!-- events are edited by hand")); // 3. The image is gone Replay("Image removed", html.Replace("<img src=\"/img/story-time.jpg\" width=640 height=360>", ""));
First target: #content > article:nth-of-type(1) > img Text changed: succeeded True edit 0: Applied {"ticket":"WEB-212"} edit 1: Applied Card inserted: succeeded False edit 0: Stale the element the locator finds has a different tag or key attributes than the target had {"ticket":"WEB-212"} edit 1: Stale the element the locator finds has a different tag or key attributes than the target had Image removed: succeeded False edit 0: Stale the locator matches nothing: #content > article:nth-of-type(1) > img {"ticket":"WEB-212"} edit 1: NotAttempted
The saved first edit looks like this:
{ "op": "setAttribute", "target": { "locator": "#content > article:nth-of-type(1) > img", "fingerprint": { "tag": "img", "attrs": { "src": "/img/story-time.jpg" }, "text": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "path": "sha256:e65c3a7e9beab0adae31be014068e912aae457e050b4d3adea04e09597f884de" }, "anchor": { "start": 1035, "end": 1087, "bytes": "sha256:1a78698d2b635aade3ba3ba8c10bcae8d4452e3c395ca1826e08b66ebf3a34ec" } }, "name": "alt", "value": "Children listening to a story", "meta": {"ticket":"WEB-212"} }
How replay decided:
- Text changed. Every byte offset after the edit moved, so the anchor no longer held. The locator still matched exactly one element, and its fingerprint matched, so both edits applied.
- Card inserted.
article:nth-of-type(1)now selects the new card. Its image has a differentsrc, so the fingerprint didn’t match and the edit isStale. A positional locator selects whatever sits at that position now, so a mismatch means stop, not guess. The second edit’s target moved the same way. - Image removed. The locator matches nothing, so the edit is
Stale. The second edit resolved but isNotAttempted, because a transaction applies all of its edits or none.
Other outcomes are Ambiguous (several candidates), Invalid and Failed. meta is yours: the library stores it and hands it back as MetaJson, and never reads it. In memory it must be JSON-shaped: strings, numbers, booleans, JsonElement, string-keyed dictionaries or sequences of those. An anonymous object throws ArgumentException.
Writing is deterministic: the same edits give the same bytes on every platform. The full format and replay rules are in Edit format, and how locators are chosen is in Locators.