Skip to content

Render Markdown in an editable face - #693

Open
jiweiyuan wants to merge 8 commits into
mainfrom
feat/domd-markdown
Open

jiweiyuan wants to merge 8 commits into
mainfrom
feat/domd-markdown

Conversation

@jiweiyuan

@jiweiyuan jiweiyuan commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Open a .md file and the rendered face is now an editor: type into the document you are reading, flip to source and back, and the two faces stay one document.

The vendored domd kernel runs in a WKWebView beside the existing reader. Its page, its fonts and the open document's own images all arrive through a single scheme handler with explicit allow-lists — the page's CSP forbids the network outright, so that handler is the only door in. The page's own source lives in web/domd; rebuilding there reproduces the committed bundle byte for byte.

What renders

Raw HTML, mermaid, KaTeX, tables, all five GitHub alert kinds, footnotes, task lists, emoji shortcodes, and YAML front matter as a metadata panel. Code is tokenized by the bundled highlight.js and set in your terminal font, so a fenced block matches the source face you flipped from.

The reader's typography comes with it — iA Writer Quattro, a capped measure, and the CJK leading and punctuation compression that block was tuned for.

Three decisions worth the review

Images and diagrams zoom, but not on click. The reader opens an image on click; an editable surface cannot, because a click there has to place the caret. Both node types take the hover zoom chip the reader reserved for diagrams, and only diagrams get wheel-zoom and pan — a raster never scales past its natural size, since enlarged pixels read as a bug.

Escape had two owners. stopPropagation never reaches cancelOperation:, so one press closed the viewer and the editor with it. The page now reports the viewer's state and the host consumes exactly one press, claiming and releasing in a single call so the race resolves the same way whichever side lands first.

Remote images are held by default and unblocked per document. A remote image in a Markdown file is a tracking pixel: opening a README tells that host your IP and when you opened it, and the file may be one an agent just fetched. The reader ships no CSP and accepts that exposure; inheriting it silently into an editor is not the same decision.

A save writes the edit, not the document

The kernel's model is canonical Markdown, so parsing a file and serializing it straight back re-pads table columns, moves blank lines and renumbers a 0.-anchored ordered list to 1. — a round trip that 110 of this repo's 164 Markdown documents do not survive. Opening a file was already safe, because the page reports nothing until the document changes. Typing one character was not: what comes back is the whole canonical document, so a keystroke rewrote the whole file.

MarkdownWriteBack is an ordinary three-way merge and closes it. The page now hands back its own serialization of the document it was just given; that is the base, the file on disk is ours, the reported document is theirs, and only what separates the last two reaches the file. Measured over all 164 documents:

  • an unedited save is byte-identical, 164/164
  • a one-character edit is a one-line diff, 164/164

Where the two sides collide — the user typed into a row canonicalization had also rewritten — the edit wins and the damage stops at that row. web/domd/scripts/writeback-corpus.mjs is the measurement; re-run it after a kernel bump.

Known open, all upstream in the domd kernel

None of these are reachable from this repo. Each has a runnable repro:

  • HTML block boundary. The kernel's block scanner runs to the matching closing tag instead of ending at a blank line as CommonMark requires, so markdown inside a centred <div> stays literal — every README with a centred header is affected. Repro: store.resetMD("<div>\n\n# Heading\n\n</div>\n") yields one HTML node and no heading.
  • Table alignment is dropped, in both directions. :---: and ---: reach neither the DOM nor the text: every cell computes text-align: left, and toMarkdown writes the delimiter row back as | - | - | - |. Repro: resetMD("| a | b | c |\n|:--|:-:|--:|\n| 1 | 2 | 3 |\n") round-trips to | - | - | - |. The write-back merge is what keeps a save from destroying an aligned table in the meantime.
  • Inline <abbr> is dropped to literal source.

One typography question to settle

The kernel sets white-space: pre-wrap on its paragraphs and keeps the source's newlines, so a hand-wrapped paragraph renders with the author's line breaks rather than reflowing to the measure. Every document in this repo is wrapped at about 80 columns, so at the 66ch measure each source line breaks again and leaves a short fragment behind it:

A paragraph of ordinary prose, long enough to run past the measure
and wrap at
least twice so the line length, the leading and the rag can all be judged
at
once rather than inferred from a single short line of sample text.

The reader this replaces reflowed — MarkdownReaderRenderer passes softBreaksAsBreaks: false. Preserving the newlines is how the kernel keeps the caret over the text model, so this is a decision to take deliberately rather than a defect to patch: either the rendered face reads this way, or the paragraph face overrides white-space and the caret mapping is re-checked against it.

Verification

swift build and swift test clean, scripts/check-strings.sh green — the two missing catalog keys that failed CI are in. The domd suite covers the scheme handler's allow-lists, the viewer's Escape ordering, the prefix guard on write-back, and the document-delivery path; MarkdownWriteBackTests covers the merge against the three shapes canonicalization actually changes, and the 164-document corpus measurement is above.

On the running app: a dev build of this branch was driven through Accessibility with the document open in the rendered face. Clicking into the prose and typing one character logged mousedown on span inEditor=true editable=true, the character appeared in the accessibility tree, and the file on disk came back with exactly this diff — the aligned table row and the 0. list untouched, which is the whole point of the merge:

-A paragraph of ordinary prose, long enough to run past the measure and wrap at
+A paragraXph of ordinary prose, long enough to run past the measure and wrap at

Flipping to the source face showed the same buffer, flipping back kept it, and one Escape closed the editor once and left the window and file browser up. The two commits after that run only move where the merge's baseline pair is set; the flip into the rendered face still seeds it exactly as the verified build did.

On screen: macOS denies Screen Recording to every agent session on this machine, so the page was rendered instead in a real WKWebView off screen — the shipped index.html, app.js, app.css and fonts, served through the same kind of scheme handler, with the host's own appearance tokens applied — and snapshotted with takeSnapshot. That is WebKit's own rendering of the real page: front matter panel, headings, wrapped prose, Chinese prose, table, blockquote, GitHub alert, highlighted Swift, task lists, KaTeX inline and display, mermaid and the footnote all render in both appearances, the page never scrolls horizontally, and prose sets in iA Writer Quattro at 18px on 1.7 leading across a 713px measure, headings take their optical negative tracking, and a document with Chinese in it moves to the CJK register's 1.8.

Release Notes: Markdown files now open in an editable rendered view. Type directly into the formatted document, click an image or diagram to open it full screen, and flip to the source at any time.

The rendered Markdown face becomes an editor. The vendored domd kernel
runs in a WKWebView beside the existing reader, reaching its page, fonts
and the document's own images through one allow-listed scheme handler —
the page's CSP forbids the network outright, so that handler is the only
door in.

Raw HTML, mermaid, KaTeX, tables, GitHub alerts and YAML front matter all
render, and the reader's type scale comes with them: iA Writer Quattro at
17px/1.6 on a 76ch measure, with the CJK leading and punctuation
compression the reader tuned.

Checkpoint. None of this has been seen on screen — macOS denies Screen
Recording to every agent session here, so the face flip, the image path
through the scheme handler, and the Settings work remain unverified.
Known open: 110 of 164 repo markdown files do not round-trip
byte-identically (tables re-padded, blank lines added, `0.` lists
renumbered).
The rendered face was unclickable everywhere below the last line of text:
the editable element sized itself to its content rather than the pane, so
those clicks landed on the page behind it and placed no caret. A flex chain
gives the editable region the full height.

Code blocks were rendering at prose size with no padding in a stack the app
never chose. The rules were written with `:where()`, which contributes no
specificity, so the vendored stylesheet won the cascade; they now apply, and
`codeTokenizer` is wired to the bundled highlight.js so code carries colour.

Images and diagrams get a full-screen viewer, ported from the reader's: an
opaque overlay in the page's own background, open and close in one frame,
vector fills the pane while a raster stops at its natural size. The reader
opens an image on click, which an editable surface cannot do — a click there
places the caret — so both node types take the hover zoom chip the reader
reserved for diagrams, and only diagrams get wheel-zoom and pan.

Escape had two owners. `stopPropagation` never reaches `cancelOperation:`,
so one press closed the viewer and the editor with it; the page now reports
the viewer's state and the host consumes exactly one press.

Remote images stay blocked by default and are unblocked per document. A
remote image in a Markdown file is a tracking pixel, and this file may be a
README an agent just fetched; the reader ships no CSP and accepts that
exposure, which is not a thing to inherit silently into an editor.

Known open, all upstream in the domd kernel and none reachable from here:
an HTML block runs to its closing tag instead of ending at a blank line, so
markdown inside a centred `<div>` stays literal; inline `<abbr>` is dropped;
canonical re-serialisation rewrites 110 of 164 repo documents on save. Each
has a repro in .domd-proto/out/UPSTREAM-ASK.md.
The caret in a contenteditable is the font's ascent and descent — 23px at
18px Quattro — and no leading changes it. At 1.5 it filled 85% of the row
and read as a bar spanning the line; at 1.7 it fills 75%, with air above
and below. Tightening leading backfires: below about 1.28 the caret is
taller than its own line box and overflows into its neighbours.
@vercel

vercel Bot commented Sep 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
termio Ready Ready Preview Sep 22, 2026 9:26pm UTC

Request Review

The two failures the localization check reported: a missing key resolves to
its English source rather than failing, so a Chinese pane would have shipped
with both of these in English and nothing would have said so.
…tting

The kernel's model is canonical Markdown, so serializing a parsed file back
out re-pads table columns, moves blank lines and renumbers a `0.` ordered
list to `1.` — a round trip 110 of this repo's 164 Markdown documents do not
survive. Opening a file was already safe, because the page reports nothing
until the document changes. Typing one character was not: the report is the
whole canonical document, so a keystroke rewrote the file.

The page now hands back its serialization of the document it was just given,
and the host merges an edit against that base instead of adopting it. Measured
over all 164 documents: an unedited save is byte-identical 164/164, and a
one-character edit is a one-line diff 164/164. Where the two sides collide —
the user typed into a row canonicalization had also rewritten — the edit wins
and the damage stops at that row.

Table alignment makes the case: the kernel drops it in both directions, so
`|:--|:-:|--:|` serializes back as `| - | - | - |`. Without the merge, saving
any document with an aligned table destroyed the alignment.
`Resources/domd/app.js` is a 400 KB built bundle whose source was not in the
repo — the README pointed at a scratch directory that exists on one machine.
`web/domd` is that source, with both versions pinned exactly: rebuilding there
reproduces the committed bundle byte for byte, so a diff means the input really
changed.
The page answers for the document it was actually handed, so the host cannot
hold a baseline from one document beside the buffer of another — a merge
against a mismatched pair rewrites lines nobody touched.
The page corrects both halves when it answers, but between the flip and that
answer only one of them was set. Seeding it with the document about to be
pushed means the pair is never half-set, whatever the page does.

This branch was successfully deployed

1 active deployment
Preview 27e0ee7d Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant