html-revisor

A human and an agent trade turns on an HTML page. The human leaves directives and edits prose in the browser; everything lands in the file.

What it's for

🤖 agent writes, then reads the diff OVERVIEW.html the only record 👩🏽‍🦱 human in the browser agent generates grep directives · git diff html-revisor formats, serves directives + prose edits The tool owns the file's shape: it may reformat, restrict, and rewrite it in place.

Where it's headed

Fidelity every scope edits as markdown, nothing lost Ergonomics the box stays out of your way Performance saves keep up on a big page Links edit as markdown links. The save carries title, target, and class back from the original. Links round-trip Tables edit as GFM tables whenever they can: thead, caption, and class survive the edit. One decision waits on Jess: a cell that holds a block, which GFM can't say. Tables as GFM Pick the enclosing scope from the box, for a scope its children cover. Up the scope Reset a scope to its text at first page load. Reset a scope Telemetry: spans for the GET and PUT steps and the box's save lifecycle, sent to Honeycomb. The other performance yaks wait on it. Telemetry Limit the size of what counts as a scope. Saves get faster, and the box holds what you meant to pick. It sits on both trails. Smaller scopes Two yaks on each trail; hover one for more. In yx, a yak on a trail carries @mountain:fidelity, ergonomics, or performance.

What's decided about editing as markdown and element identity, beside examples: SCOPES-DESIGN.html. How everything the tool adds to a page looks, and the design decisions so far: DESIGN.html.

Architecture

GET page.html file on disk parse, normalize rewrites disk if changed assign IDs in the file, never reused lift directives comments → JSON inject client + bases, markdown, hash browser PUT page.html {one change: id, base, directive or markdown} sent 0.4 s after you stop typing browser conflict check one element, by base set prose reconciled HTML place directive the one, by ID serialize IDs stay in file on disk Modules I · orchestration II · domain pure functions III · interface server.mjs CLI, routing, the GET and PUT flows formatter.mjs canonical form, IDs scopes.mjs scope ↔ markdown directives.mjs find, place server.mjs filesystem, client assets linkedom HTML parser markdown-it markdown → HTML browser client.js hover, box, docket, PUT client.css every style the tool adds server.mjs inlines both into each page on GET Dependencies point down. The three domain modules import each other. linkedom and markdown-it are vendored bundles.

⌘-click a box for its step below. (A plain click opens a directive box.)

How it reaches you

html-revisor main: code, tests, yaks iterate-on-docs html-revisor/ the copy you run GitHub version tag installed plugins ./deploy plain copy, tests, commit ./release bump, tag, --push Contract README table, incl. words iterate-on-docs skills + README out of band we edit, own commit deploy carries the code; a Contract change travels beside it. If they disagree, the Contract wins.

./deploy runs from main only and refuses on a dirty tree. If the tests fail in the copy, it puts the copy back and commits nothing. Details: README, “Deploy” and “Contract with iterate-on-docs”.

Step by step

The pipelines above, stood on end. Green: today. Dashed purple: directional.

GET page.html
file on disk
parse, normalize
format

Parse with linkedom, rewrite into canonical shape: add html/head/body if missing, b→strong and the other forms markdown writes back, one block per line, inline runs on one line, whitespace collapsed, two-space indent. Idempotent. Writes to disk when it differs.

on disk, before
<h1>Plan</h1>
<p>Ship   the <b>formatter</b>
first.</p><ul><li>one</li><li>two</li></ul>
<img src="map.png">
on disk, after
<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <h1>Plan</h1>
    <p>Ship the <b>formatter</b> first.</p>
    <ul>
      <li>one</li>
      <li>two</li>
    </ul>
    <img src="map.png">
  </body>
</html>

format directional

Normalize is its own command, in formatter.mjs; GET runs the same code. The agent runs it after writing the page, before the human opens it. Directional: validation rules, for what normalizing can't fix. None yet; one a rule could report:

$ node formatter.mjs OVERVIEW.html
OVERVIEW.html: <p> holds a <div>; a paragraph holds phrasing content only
assign IDs

One element ID per scope (every block), and per link, image, hr, and part of an SVG. The formatter writes them into the file, from a counter whose high-water mark sits on <html>, so an edit can't shift them. It assigns IDs on every GET and first thing on every PUT. An ID never changes and is never reused; duplicates (an agent's copy-paste) retire the ID, and every copy gets a fresh one.

on disk
<html data-hrv-next="8">
…
<p data-hrv="4">Ship the <b>formatter</b> first.</p>
<ul data-hrv="5">
  <li data-hrv="6">one</li>
  <li data-hrv="7">two</li>
</ul>
lift directives
on disk
<p>
  <!-- @AI: say why first -->
  Ship the <b>formatter</b> first.
</p>
served
<p data-hrv="4">Ship the …</p>
…
<script type="application/json"
        id="hrv-directives">
  {"4":"say why first"}
</script>

Like an HTML comment, a type="application/json" script is inert: the browser never runs it. The client reads it with JSON.parse(el.textContent).

in the browser
✎
yep, this is the marker that shows a directive is here

Ship the formatter first.

⌘-click the marker. Clicking a real one opens its directive.

inject client
+ markdown editor
served
<html>
  <head>
    … the page's head …
    <style> hover, markers, box </style>
  </head>
  <body>
    … the page, with its IDs …
    <script type="application/json" id="hrv-directives"
            data-version="3f9a0c1b2d4e"> {…} </script>
    <script type="application/json" id="hrv-bases"> {"4":"a1c4…"} </script>
    <script> hover · click · box · PUT </script>
  </body>
</html>

The green lines exist only in the served copy, never in the file, so the client's own elements have no ID and can't take a directive. data-version fingerprints the file on disk; a save's answer says whether the file changed (see conflict check). hrv-bases holds each element's base, which a save sends back.

browser
PUT page.html {id, base, directive} or {id, base, markdown}
browser

Sent 0.4 s after you stop typing. Carries one change, a directive or a scope's markdown, with the base it started from. Changes go out one at a time.

conflict check

No file-wide version. The server compares the change's base with the element now, directives and IDs stripped. Only that change can conflict. A changed child changes its parent, so nesting needs no special case.

{id: "6", base: "a1c4", directive: "say more"}
  base matches li 6        →  applied; answer has new bases
  base differs, or no li 6 →  412, nothing written: error, reload suggested

A conflict means we weren't taking turns; it's an edge case. An agent's edit elsewhere doesn't block the save. The answer carries the file's hash before the save; if that isn't the page the tab loaded, the tab says "the page changed on disk; reload to see it". directional Later the tab hears of it live.

set prose
{id: "4", base, markdown: "Ship the **formatter** last."}
  →  <p data-hrv="4">Ship the <strong>formatter</strong> last.</p>

Any scope. The answer carries the new HTML, as served, and the new markdown of the scopes around it; the tab swaps the HTML in. Blank markdown, body, and elements that aren't scopes (hr, an SVG shape) are refused.

Open, then save unchanged: the HTML stays byte-for-byte the same. See Prose editing.

Saving reconciles with the prior HTML: an element the edit leaves alone, moves, or changes keeps its ID and what markdown can't say (img width and height). New elements get fresh IDs, sent back to the tab.

place directive

A save carries one directive; empty text removes it. The rest stay where they are, so a directive the agent finished and removed can't come back. First child of its element; before a void or raw-text element (img, hr, pre…).

on disk
<p>
  <!-- @AI: say why first -->
  Ship the <b>formatter</b> first.
</p>
<!-- @AI: needs alt text -->
<img src="map.png">
serialize

Write the canonical format, IDs and all. IDs are part of the file. The first format of each page is one noisy diff; after that a diff shows only new IDs.

directional Before a page leaves the tool, a cleanup script strips them.

file on disk

Prose editing

Every scope edits as markdown current

A paragraph with bold and a link.

p · A paragraph with bold and a link.
Content
Directive
⌘↵ or esc to close
Delete
Done

Pick the paragraph, the list, one item, or the section around them: each is a scope and edits as markdown. Saving sends the markdown; the server answers with the HTML, and the page swaps it in. Elements you left or changed keep their IDs and directives.

Invariants

Terms

directive
A request from the human to the agent, stored as an HTML comment starting @AI:. Avoid: note, comment, annotation.
normalize
Rewrite the file into canonical shape. See parse, normalize.
element ID
How the browser and server name an element: data-hrv, written into the file by assign IDs.
scope
The unit of editing and directives: any block you pick, plus links, images, and spans, edited as markdown as a whole. Scopes nest.
Contract
What iterate-on-docs relies on, words included: the table in README. See how it reaches you.
deploy, release
Deploy copies main into iterate-on-docs. Release, run there, versions and publishes it. A deploy is not a release.
phrasing content
HTML-spec term: text plus inline markup (b, a, code…). See MDN: content categories.

Current work

Open questions
  • What do we call the two kinds of element? The spec's "flow content" includes <p>.
  • Directives inside a scope you edit as markdown: where do they go?
Tracker

Work lives in yx. Run yx list.

  • Spec: html-revisor-spec.md
  • Usage: README.md
  • Research notes: commit 8683442