An unfolding · nine working versions · one dead branch · one field repair

The Unfolding of an
HTML Text Editor

<section class="hero"><h1>Edit this text in peace</h1><script>…</script></section>

This is the build log of Galley, a WYSIWYG editor for the available text of an HTML file — the words between the tags. It is not a DOM editor, not a page builder, not a pretty-printer. You open a file (a single-file app, a report, anything), click the text, retype it, and download. The CSS, the JavaScript, the markup, the comments, even the exact entity spellings of everything you didn't touch come back byte for byte. Galley itself is one HTML file, entirely client-side. It grew in the nine layers below, each one a complete working editor, each one standing on the last — with one branch, kept and shown, that had to be abandoned, and one repair prompted by the first person outside the workshop to run it.

The promise, made before the first line

1

Round trip

file in, file out, and the fidelity check everything else answers to

Since Galley is entirely client-side, the first layer is the file's journey: an Open button reads the HTML into memory as a string, a raw source view shows what arrived, and a Download button hands the string back as a file. Nothing in between — yet.

The important part is one line in the download handler: it compares the outgoing text with the original and reports ● output identical to source in the status bar. In step 1 this check is trivially true. It is there because every later layer must keep it true, and the moment one doesn't, we want the tool itself to say so. (It will.)

Try it  Open sample-page.html, press Download, and diff the two files: they are identical.

editor-v1.html
2

Safe preview

the file rendered as a page, with its own scripts held inert

To edit text where it lives, we must render the file. But the files we care about are single-file applications — they carry live scripts, and an editor must not run the thing it is editing. The preview is an <iframe> whose sandbox attribute grants allow-same-origin and deliberately withholds allow-scripts: the page's stylesheets apply and its layout is real, but its scripts, inline handlers and javascript: URLs are all inert. allow-same-origin is what lets Galley reach inside the frame in later steps — safely, because nothing in the frame can run.

A Preview / Source toggle joins the toolbar. Download still returns the original bytes; the check still shows green.

A defect is born here  allow-same-origin only grants access if a shared origin exists — and under the file:// protocol, browsers give every local file a unique, opaque origin, so the condition can never be met and the editor's reach into its own preview is refused. Nothing in the workshop caught it, because the workshop never opened the tool by double-clicking a file. It rode along unnoticed through six more steps, until the first outside user did — see the notes from the field and step 9, the repair.

Try it  The sample page has a click-counter button. In the preview it does nothing — exactly as intended.

editor-v2.html
3

Text map

every run of available text located at its exact address in the source

The step that replaces the branch. A small scanner walks the raw source string — never a parsed DOM — and records every run of available text with its exact character range:

// text is what lies between the tags; everything else is skipped:
// comments, doctypes, tag internals (quotes respected), and the
// raw-text elements whose content is code, not prose
scanText(src)  [ { start, end, kind: "text" | "title" }, … ]

The preview is then rebuilt from the source, with each run wrapped in a marker <span data-uf=N> carrying its index into the map — so every piece of text on screen knows its address in the file. The wrappers exist only in the preview; the source string itself is never touched. <script>, <style> and <textarea> contents are skipped as code; <title> is recognized as text but flagged specially, because a span cannot live inside it (it waits until step 8). Nothing is editable yet — a Show texts button outlines what the map found, and the status bar counts it.

Try it  Press Show texts on the sample page: twelve outlined runs; the script's strings, the comment and the CSS stay dark.

editor-v3.html
4

Click to edit

editable runs, and saving as a pure splice over the map

The marker spans gain contenteditable="plaintext-only" (falling back to "true" where unsupported — step 5 covers that gap). One delegated input listener inside the preview captures every change into a map of segment index → new text. Edited runs get a dotted amber underline.

Download is where the branch's lesson pays off. Saving is string surgery on the original source, guided by the step-3 map:

// for each run: original bytes if untouched, escaped new text if edited
out += edited ? escape(newText) : src.slice(start, end)
// and the promise, restated for an editor that edits:
// the rebuild WITH EDITS REMOVED must equal the source, byte for byte

That zero-edit rebuild check now runs the moment a file opens, and the status bar reports ● map verified — the tool proving, per file, that its map reassembles the document exactly.

Try it  Rewrite a list item, download, and diff: the file differs in that one text run and nowhere else — &mdash; and friends intact everywhere you didn't type.

editor-v4.html
5

Guardrails

edits can only ever be plain text, in every browser

Browsers ship a rich editing engine, and richness here would smuggle structure into the file through the back door. This layer refuses it uniformly: beforeinput cancels formatting commands (Ctrl+B and friends do nothing), Enter is intercepted and inserted as a single line break (<br> in the preview, read back as \n), paste is stripped to text/plain, drops are refused, and a sentinel flattens any element that still sneaks inside a text span. The fallback browsers from step 4 now behave exactly like the plaintext-only ones.

On save, edited text is escaped — &, <, >, and non-breaking spaces — so even typing <b>hi</b> stores harmless text. The file can gain words; it cannot gain structure.

Try it  Copy a colourful headline from any web page and paste it into the sample's heading: it arrives as bare words.

editor-v5.html
6

Change tracking

the edits map made visible, reversible, and hard to lose

The in-memory edits map has been the whole story since step 4; this layer gives it a face. An Edits panel lists every rewritten run — original struck through beside the replacement — with Show (scroll to it and flash it) and Revert on each, plus Revert all. Reverting is just deleting the map entry: the original bytes were never touched, so there is nothing to restore in the source. Typing a run back to its exact original text quietly drops it from the ledger too.

The Download button carries a dot while changes are unsaved, and the browser warns before the tab closes on unsaved work.

Try it  Make three edits, revert the middle one from the panel, and watch the status count follow.

editor-v6.html
7

Find & replace

search over the text and only the text

Search runs against each run's current value — the edit if there is one, else the original — so a query can never match inside a script, a style, a comment or a tag: the map from step 3 simply doesn't contain them. Matches are counted (3 of 17), and stepping through them selects the exact characters in place in the preview.

Replace routes through the same write path as typing, so replacements appear in the step-6 ledger and can be reverted like any other edit. Replace all sweeps every run at once — and the fidelity guarantee is untouched, because a replacement is just an edit.

Try it  On the sample page, search spool. It matches twice, both in visible prose — and never in the script, where var spools sits unfindable and unharmable.

editor-v7.html
8

Finishing touches

the conveniences, each riding on machinery that already exists

This layer adds no new architecture — that is the point. Drop an HTML file anywhere on the window to open it. Ctrl/⌘+S downloads, Ctrl/⌘+F opens find, Esc closes it — from the page or from inside the preview. Opening a new file over unsaved edits asks first. A small ? explains the tool in its own toolbar.

And the <title> — found by the map in step 3, unwrappable ever since — finally becomes editable through its own toolbar field, flowing through the same edits map, the same ledger, the same splice as every other run. The oldest open thread in the sequence, closed by the newest layer.

Try it  Retitle the sample page, rewrite its header, press Ctrl+S, and diff the download against the original: two small islands of change in an untouched sea.

editor-v8.html
9

Field repair

the preview becomes a program we message, not a document we reach into

The first person outside the workshop ran the sequence the most natural way possible — double-clicking the files — and sent back two console messages (quoted below). One was noise; one was a defect that had been riding along since step 2: everything from step 4 on assumed the editor could reach into its preview through contentDocument, and under file:// that reach is refused, because local files never share an origin.

The repair inverts the relationship instead of patching it. The frame becomes sandbox="allow-scripts"allow-same-origin is dropped entirely, which makes the sandbox strictly stronger — and the preview gains a small controller: a script Galley injects into the rebuilt document, right after the doctype, holding a Content-Security-Policy nonce. The controller owns all DOM work inside the frame (the guardrails, the edit reports, jump, select, revert); the parent owns the source, the map and the edits; and the two halves speak only postMessage, which works across any origins — file:// included. Each render carries a session number stamped on every message, so a stale frame can never speak for a fresh one.

The edited page's own scripts, which the old sandbox silenced (loudly — that was the noise), are now neutralized before rendering: the same kind of source walk as step 3 gives every script tag a first-position type="text/plain", preview-only, with the CSP as backstop against inline handlers and javascript: URLs. And the part of Galley that earns its keep: the text map, the splice, and the fidelity check did not change at all. The defect lived entirely in the transport between the two halves; the promise never depended on it.

Try it  Download this version, double-click it from your file manager — no server, no setup — and edit the sample page. The console stays quiet.

editor-v9.html

The tower, read from the ground up

Every layer depends on all the layers beneath it — remove one and everything above it falls.

  1. 1 Round trip — the file, and the fidelity check all later layers answer to
  2. 2 Safe preview — the file rendered, its scripts held inert, reachable from outside
  3. 3 Text map — every text run addressed in the source; the DOM never serialized
  4. 4 Click to edit — edits keyed to the map; saving as a pure splice
  5. 5 Guardrails — edits constrained to plain text in every browser
  6. 6 Change tracking — the edits map visible, reversible, guarded against loss
  7. 7 Find & replace — search bounded by the map, writing through the same path
  8. 8 Finishing touches — shortcuts, drag-drop, and the title thread tied off
  9. 9 Field repair — parent and preview split into two programs joined only by messages

The files

Notes from the field

After the sequence first shipped, its first outside user ran it from disk and sent back two console messages. This is the entire report, and it was enough.

  Blocked script execution in 'about:srcdoc' because the document's
    frame is sandboxed and the 'allow-scripts' permission is not set.

  Unsafe attempt to load URL file:///…/editor-v8.html from frame with
    URL file:///…/editor-v8.html. 'file:' URLs are treated as unique
    security origins.

Message ① turned out to be the sandbox working: the step-2 frame refusing to run the sample page's script, exactly as designed — but reported so alarmingly it reads like a failure. Step 9 silences it by neutralizing the page's scripts before they ever reach the frame.

Message ② was the defect, introduced in step 2 and latent through every version since: under file:// each local file is its own unique origin, so allow-same-origin grants nothing and the editor cannot reach into its own preview. The message names the editor's file twice because the browser reports the srcdoc frame under its parent's URL — the editor, refused entry to itself. The repair is step 9; the marks it left are the rust note in step 2 and this section. A tool whose whole promise is honesty about bytes should keep an honest changelog too.

Honest edges of the design: files are read and written as UTF-8; text that lives in attributes (alt, placeholder, aria-label) is out of scope by the brief's definition of available text; an edited run is re-encoded with minimal escaping, so an untouched &mdash; survives as written but a rewritten run stores its characters directly; and the preview is an approximation — relative images may not resolve, and from step 9 the page's own scripts are neutralized in the preview (never in the file) — while the saved file is not an approximation of anything.