3 The block model & protocol
The document as a list of identified blocks: the content-hash contract, the diff that deliberately has no Move, and click-to-source.
Everything in Taliesin keys off one idea: the document is a list of blocks,
each of which knows its identity and its origin. Our post.tmd is three of them,
the # Cooling coffee heading, the paragraph, and the Python cell, plus a fourth
the cell produces: the plot, a derived block keyed to the cell.
3.1 The block model
Every top-level element is a block. Each block carries three data attributes that the rest of the system keys off:
data-block-idis a content hash (with a positional tiebreak for duplicates). Because it is content-derived, an unchanged block keeps its id across edits and is never touched.data-sourceposis thestartLine:startCol-endLine:endColrange the block came from. This is what powers click-to-source in both directions.data-source-fileis present only on blocks pulled in from an{{< include >}}d file, so cross-file source mapping still works.
Source mapping, incremental re-render, and live-state preservation all build on this one model. A code cell’s output is itself a block, keyed to the cell’s id, so when an upstream cell re-runs, only the affected output blocks update.
The exact field shapes (Block, Cell, RenderedDoc, and friends) live in
crates/core/src/render/model.rs, one small file with a doc comment per field; this
chapter explains how the diff and the protocol use them.
3.1.1 How the id is computed
make_id (render/mod.rs) is small and deterministic, so two renders of the same
source always agree and the diff has a stable anchor:
- Take the block’s raw Markdown source, trimmed of leading and trailing whitespace.
- Hash it with 64-bit FNV-1a (offset basis
0xcbf29ce484222325, prime0x100000001b3), the same hash the freeze cache uses for cell keys. - Format the digest as 16 hex digits, keep the first 12, and prefix
b-. So an id looks likeb-3af19c0b7e21. - If a byte-identical block already produced that base id, append a positional
-Nsuffix (the first duplicate becomesb-…-1, the nextb-…-2, and so on), viadedup_with_suffix. This is the only place position enters the id, and only to break exact-content ties, so ids stay unique within a document.
The hash is over content, not position, on purpose. Inserting a paragraph at the
top of a document leaves every following block’s id unchanged, so the diff sees one
insert rather than “everything moved”. The -N tiebreak is what lets the diff
treat ids as unique (the basis for the O(n log n) shortcut below).
A generated block has no source text of its own (the synthesized title header, the
References block, the footnotes section), so it skips the hash entirely and takes a fixed
id with an empty data-sourcepos.
3.2 The diff (diff.rs)
diff_blocks(old, new) turns two block lists into the minimal set of DOM ops the
client applies. Because ids are content hashes, an unchanged block has the same
id in both lists, so the algorithm is an LCS over the id sequences:
- The LCS finds the matched id pairs: the stable anchors that didn’t move.
- The runs of blocks between consecutive anchors are the changed regions.
emit_gappairs them positionally: each pair → anUpdate(replace the old block’s element with the new HTML); a surplus of old blocks →Remove; a surplus of new blocks →Insertafter the preceding block’s id (or at the start when there is none). - A matched anchor is normally left untouched. When its HTML does differ,
anchor_opchooses between two outcomes by comparing the bodies with the source-position attributes masked out:- if only
data-sourcepos/data-source-filemoved (an edit above shifted the block’s line numbers, but its content is byte-identical), it emits aSetMeta: patch those two attributes in place, with no re-render, so the live element keeps its DOM state; - if the body itself changed, it emits an
Update. This is the path a derived block whose id isn’t a pure content hash (a code cell’s output block, keyed to the cell id) takes when an upstream cell re-runs.
- if only
3.2.1 How SetMeta vs Update is decided
The “with the source-position attributes masked out” step is eq_ignoring_sourcepos, a
precise string comparison: mask_sourcepos rewrites every data-sourcepos="…" in
both htmls to an empty data-sourcepos="" (the value dropped, the attribute and
quotes kept), and the two are then compared byte for byte. data-sourcepos is the
only thing masked, so a block that kept its content but moved to a different file
does not read as equal, and takes Update.
Two guards run before the mask, though, and the second is what actually decides the
expensive case. anchor_op is only reached at all when the two htmls differ; and it
begins by counting the data-sourcepos attributes in the new html (sourcepos_count).
A block carrying more than one, which is any ::: div, whose inner blocks each carry
their own, is never eligible for SetMeta however the mask compares: patching the outer
attribute would leave every inner one stale, so it falls through to a full Update.
The whole decision is mechanical once the anchors are known (Figure 3.1):
flowchart TD
lists["old block ids vs new block ids"] --> lcs["LCS over the ids"]
lcs --> anchor{"matched anchor?"}
anchor -->|"HTML identical"| keep["leave untouched"]
anchor -->|"only sourcepos moved"| sm["SetMeta"]
anchor -->|"body changed (cell output)"| u1["Update"]
lcs --> gap{"gap between anchors"}
gap -->|"old / new pair"| u2["Update"]
gap -->|"surplus old"| rem["Remove"]
gap -->|"surplus new"| ins["Insert after prev id"]
3.2.2 Why the diff is O(n log n)
Calling this “an LCS” is a lie of omission. The textbook LCS is an O(m·n)
dynamic-programming table, and on a save it would be reallocated on every
keystroke: tens of megabytes once a document reaches a few thousand blocks.
Taliesin never builds it.
The shortcut is that block ids are unique within a document. A common
subsequence is then just a set of shared ids in the same relative order, so the
LCS collapses to a Longest Increasing Subsequence: map each old block to its
position in the new list, and the longest still-increasing run is the set of
anchors that didn’t move. LIS is solved in O(n log n) time and O(n) space by
patience sorting, which is what lcs_pairs does. The cost of a diff is
therefore dominated by the handful of blocks that actually changed, not by the
size of the document.
The payoff: an unchanged block is never touched, so the browser keeps its
scroll position and any live DOM state inside it (a running Three.js canvas, a
{js} cell) survives a save untouched (goal #2).
Concretely: change tau and the diff emits exactly one op, an Update for the
plot’s block, while the heading and the paragraph, their content hashes unchanged,
never move.
A real warm edit shows what this costs in bytes, not just in count. Typing one new
paragraph above an already-open collapse callout, benchmarked by tools/live-edit-bench,
emits 55 operations: 53 of them (every shifted paragraph, heading, figure and code cell
below the edit) are SetMeta patches like the one above, so anything live in those blocks
keeps running; together with the one insert for the new paragraph itself, those 53 weigh
about 3.2 KB. The remaining 29 KB is a single Update: the open
::: {.callout-note collapse="true"} div, whose inner blocks each carry their own source
position and so must fully re-render to keep Ctrl-click accurate. One block shape costs
90% of the payload; every other block costs bytes proportional to nothing but its own line
number. The tradeoff is deliberate: its one visible cost is that a collapse callout you
have opened closes again when an edit lands above it. (See Choosing
Taliesin for the full measurement.)
There is no Move op. Reordering two blocks keeps one as an anchor and rebuilds
the other as remove + insert, so a moved live block loses its runtime state.
This is the deliberate trade of content-hash ids (a pinned regression test
documents it); moves are rare in editing, and the simplicity is worth it.
3.2.3 Removes before inserts (the ordering rule)
diff_blocks produces the ops anchor by anchor in document order, then does one
last thing: a stable sort that hoists every Remove ahead of every Insert.
The client applies the ops in the order it receives them, so this ordering is load
bearing.
The reason is exactly the no-Move case above. A block that moves forward is
emitted as a Remove at its old slot plus an Insert of the same id at its new
slot. If the Insert ran first, the new element (carrying that id) would already be
in the DOM when the Remove ran, and Remove’s id-based lookup would find and
delete the freshly inserted element instead of the stale one. Hoisting the
Remove makes the old element gone before the new one arrives, so the lookup is
unambiguous. Removes are positionally independent (each names a concrete
target_id), so reordering them is safe; the sort is stable, which keeps the
Inserts in document order so their after_id chains still resolve.
3.3 The websocket protocol
On connect, the server sends a full_render; on each save it diffs the new block
list against the old one (an LCS over block ids) and broadcasts only the
differences.
| Direction | Message | Payload |
|---|---|---|
| server → client | full_render | title, body_html, diagnostics, gen, boot |
| server → client | update | target_id, html, gen |
| server → client | insert | after_id, html, gen |
| server → client | remove | target_id, gen |
| server → client | set_meta | target_id, sourcepos, source_file, gen (patch a shifted block’s source mapping in place) |
| server → client | title | title (retitle the tab with no re-mount, when the front matter’s title changed but the body did not) |
| server → client | build-state | page, phase, ran, total, lang (document-level execution progress, k of N) |
| server → client | cell-state | page, cell_id, state, started_ms, duration_ms, source ("cache" or "fresh": the queued / running / done badges, and the ⚡ cached one that answers “why didn’t my cell re-run?”) |
| server → client | cell-output-append | page, cell_id, op, html (a preview of a still-running cell’s output, so a long job shows its log rather than a bare ⏳; the authoritative version still arrives as a block update, which is why build emits none of these) |
| server → client | diagnostics | messages (non-fatal include / kernel / extension issues) |
| server → client | error | message (a failed render; shown as an overlay) |
| server → client | reload | (none); full page reload (config change, kernel restart) |
| client → server | click_block | block_id, source_file, sourcepos |
| client → server | restart_kernel | (none); drop + respawn the kernel, re-run all cells |
These message shapes are defined once, in protocol.rs, and shared by the dev
server and the browser client. serve_site/ and web-client/ are the two ends of this
one contract, so they can’t drift apart from each other. The update / insert / remove / set_meta messages are
just the serialized BlockOp enum (the variant name lowercased into the type
field); its four Rust variants and their fields are declared in crates/core/src/diff.rs.
For the exact JSON shape of every message and when each is sent, read
crates/server/src/protocol.rs; for how the client applies one, web-client/client.js.
3.3.1 Applying the ops (the client)
The browser side (web-client/client.js) is deliberately thin: each op is one small
DOM mutation, and the diff above is what keeps that set small.
updatefinds the target by id andreplaceWiths the new HTML;insertbuilds a fragment and places it afterafter_id(orprepends it when there is none);removedrops the element;set_metapatches onlydata-sourcepos/data-source-fileon the existing element and does not re-render.- Every mutation runs inside
keepScroll, which pinswindow.scrollYacross the change (instantly, never animated), and is followed byafterChange, which rebuilds the TOC, re-runs the code enhancers, and re-scans for cell errors.set_metaskipsafterChange(nothing rendered). - The first paint is server-rendered into
#tali-root, so the very firstfull_renderafter connect is identical and is skipped (no flash, no needless{js}re-init). A reconnect re-mounts normally, and a dropped socket retries on a short loop. - Because block ids are unique, an
insertfirst drops any element already carrying the incoming id. The server emits allremoves before anyinserts, so this is normally a no-op; it only matters if a reorder (aremove+insertof the same id) ever arrived out of order.
3.4 Inverse search & forward search
Click-to-source (goal #1) rides entirely on the two source attributes:
- Inverse search (preview → editor). Ctrl-clicking (Cmd-clicking on Mac) a block
reads its
data-sourcepos(+data-source-file) and opens the exact source line: avscode://file/<path>:<line>:<col>deep link by default. The source positions start as comrak’s per-node ranges; on the way to becoming blocks they are translated through the include source map (includes.rsrecords aLineOriginfor every expanded line → its origin file + line), so a block that came from an{{< include >}}d partial carries that file indata-source-fileand the original line: click-to-source works across files. - Forward search (editor → preview). The client also speaks a
postMessageprotocol as the integration surface for an embedded editor host. The in-repo VS Code companion is that host: the client poststali-gotoup to it (which reveals the source), and acceptstali-cursor {file, line, reveal}. That last one runs the block model backwards:highlightAtLinepicks the smallest block whosedata-sourceposcovers the line (else the nearest block starting before it) and outlines it with.tali-hl, scrolling it into view only whenrevealis set. So an editor can host the preview and add cursor sync without forking the client.
Both directions only navigate and highlight: the .tmd file stays the single editing
surface, and nothing in the preview or the editor-client integration ever writes back to
it.