The editor behind our managed-website product is built for people who have never heard
of markdown. Under the hood it runs marktile’s engine, which is deliberately
single-layer: the text you see is the document, and styling is a costume worn over
the source. Headings hide their ## and stay big; bold hides its ** and stays bold.
The text never forks into a second model, so saving is trivially exact.
Then a table walks in. Tables are the one markdown construct where hiding the syntax
isn’t enough — strip the pipes from | name | role | and you’re left with floating
words. To look like a table, text needs two-dimensional layout. The received wisdom
says this is where you stop pretending: build a widget, render a real table, give it
its own input model, map every edit back to the source.
The received wisdom is heavy. prosemirror-tables is 447KB unpacked and wants the
ProseMirror trinity under it — model, state, view, about 1.6MB — plus a schema’d,
dual-representation document. CodeMirror 6’s chassis is ~1.9MB before any table
extension. Obsidian, built by people who know this domain cold, didn’t ship in-table
editing until 1.5. We had already half-resigned ourselves to that road.
Then, mid-conversation, a different question: what if we never leave the text? Three small facts, stacked:
1. Source lines are already table rows. Each line of a markdown table is its own
div in the editor. Give those divs display: table-row, wrap cells and pipes in spans
(textContent untouched — the round-trip stays byte-exact), and the browser’s
anonymous table box does the layout. It measures real glyphs, so CJK columns align
to the pixel — something space-padding mathematically can’t do, because a CJK glyph
isn’t an integer multiple of a space. We measured the gap: space-padding drifted
29–39px in our font; the table box, 0.
2. Lock the syntax. The hidden pipes become contenteditable="false", and a
beforeinput guard rejects any deletion whose target range would cross a pipe, a
cell, or a row boundary. The structure is physically indestructible — mash Backspace
at a cell edge all day — and whatever you type lands in the cell’s text node, so the
markdown underneath is correct by construction.
3. Re-wrap before paint. The engine rebuilds an edited line’s DOM, wiping our spans. But MutationObserver callbacks are microtasks, and microtasks run before paint: re-wrap synchronously in the callback, restore the caret by character offset, and the costume change happens while the curtain is down. Zero flicker — not as a tuning achievement, but by construction.
Add Tab to hop cells and Enter to insert a row (both intercepted in capture phase, so the engine never sees them) and you have honest in-grid editing: type in a cell and the columns re-measure live, the header follows, undo works. One WebKit bug fought back — anonymous table boxes don’t propagate one row’s width change to its sibling rows — so on each edit we kick the block out of table context and back, also pre-paint, also invisible. Total bill: 250 lines of vanilla JS, 8 CSS rules, zero dependencies.
Prior art, honestly: every ingredient exists somewhere. HyperMD aligned source tables years ago by measuring column widths in JS — pipes visible, no grid, no locks. Everyone else took the widget road. The combination — source lines as table rows, locked invisible delimiters, single-layer round-trip-exact editing — we could not find anywhere public. Some closed-source editor may have invented it quietly; if you know of one, I’d genuinely love to hear about it.
The honest caveat is also the moral. This works because the engine underneath is small enough to be honest — a line is a div, the text is the textContent, there is exactly one layer. On a cathedral-sized editor framework you can’t reach these seams. Sometimes the lightest solution isn’t a better library. It’s noticing the layout engine that has been sitting in every browser since CSS 2.1, and letting your source code wear it.