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
The file is sacrosanct Outside the texts you rewrite, the download
is the upload, byte for byte. No reformatting, no entity normalization, no
helpful cleanup. A check that proves this ships in version 1 and never leaves.
Every version works end-to-end Each step is a tool you could stop
at and use: open → do its thing → download. No scaffolding-only commits.
Each layer stands on the last Later steps only add; they never
re-architect. When an architecture couldn't carry the next layer, we went
back to the fork and grew a new step — and kept the dead branch in the record.
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.
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 hereallow-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.
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.
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 — —
and friends intact everywhere you didn't type.
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.
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.
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.
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.
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.
sample-page.html — a small single-file app (styles, a live script, entities, a comment) to practise on
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
— 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.