Skip to content
Taliesin Internals

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-id is 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-sourcepos is the startLine:startCol-endLine:endCol range the block came from. This is what powers click-to-source in both directions.
  • data-source-file is 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:

  1. Take the block’s raw Markdown source, trimmed of leading and trailing whitespace.
  2. Hash it with 64-bit FNV-1a (offset basis 0xcbf29ce484222325, prime 0x100000001b3), the same hash the freeze cache uses for cell keys.
  3. Format the digest as 16 hex digits, keep the first 12, and prefix b-. So an id looks like b-3af19c0b7e21.
  4. If a byte-identical block already produced that base id, append a positional -N suffix (the first duplicate becomes b-…-1, the next b-…-2, and so on), via dedup_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:

  1. The LCS finds the matched id pairs: the stable anchors that didn’t move.
  2. The runs of blocks between consecutive anchors are the changed regions. emit_gap pairs them positionally: each pair → an Update (replace the old block’s element with the new HTML); a surplus of old blocks → Remove; a surplus of new blocks → Insert after the preceding block’s id (or at the start when there is none).
  3. A matched anchor is normally left untouched. When its HTML does differ, anchor_op chooses between two outcomes by comparing the bodies with the source-position attributes masked out:
    • if only data-sourcepos / data-source-file moved (an edit above shifted the block’s line numbers, but its content is byte-identical), it emits a SetMeta: 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.

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"]
Figure 3.1: How diff_blocks turns two block-id lists into DOM ops.

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.)

The one limitation: no Move

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.

DirectionMessagePayload
server → clientfull_rendertitle, body_html, diagnostics, gen, boot
server → clientupdatetarget_id, html, gen
server → clientinsertafter_id, html, gen
server → clientremovetarget_id, gen
server → clientset_metatarget_id, sourcepos, source_file, gen (patch a shifted block’s source mapping in place)
server → clienttitletitle (retitle the tab with no re-mount, when the front matter’s title changed but the body did not)
server → clientbuild-statepage, phase, ran, total, lang (document-level execution progress, k of N)
server → clientcell-statepage, 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 → clientcell-output-appendpage, 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 → clientdiagnosticsmessages (non-fatal include / kernel / extension issues)
server → clienterrormessage (a failed render; shown as an overlay)
server → clientreload(none); full page reload (config change, kernel restart)
client → serverclick_blockblock_id, source_file, sourcepos
client → serverrestart_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.

  • update finds the target by id and replaceWiths the new HTML; insert builds a fragment and places it after after_id (or prepends it when there is none); remove drops the element; set_meta patches only data-sourcepos / data-source-file on the existing element and does not re-render.
  • Every mutation runs inside keepScroll, which pins window.scrollY across the change (instantly, never animated), and is followed by afterChange, which rebuilds the TOC, re-runs the code enhancers, and re-scans for cell errors. set_meta skips afterChange (nothing rendered).
  • The first paint is server-rendered into #tali-root, so the very first full_render after 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 insert first drops any element already carrying the incoming id. The server emits all removes before any inserts, so this is normally a no-op; it only matters if a reorder (a remove + insert of the same id) ever arrived out of order.

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: a vscode://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.rs records a LineOrigin for every expanded line → its origin file + line), so a block that came from an {{< include >}}d partial carries that file in data-source-file and the original line: click-to-source works across files.
  • Forward search (editor → preview). The client also speaks a postMessage protocol as the integration surface for an embedded editor host. The in-repo VS Code companion is that host: the client posts tali-goto up to it (which reveals the source), and accepts tali-cursor {file, line, reveal}. That last one runs the block model backwards: highlightAtLine picks the smallest block whose data-sourcepos covers the line (else the nearest block starting before it) and outlines it with .tali-hl, scrolling it into view only when reveal is 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.