5 Extending Taliesin
Where a new feature belongs, the one seam meant to be extended, what an editor gets from the language server, and how a refactor is checked.
This chapter covers the conventions the code follows, the one seam meant to be extended without touching the core, how an editor gets its intelligence, and how a refactor is checked for byte-identical output.
5.1 The guiding principle: a lean core
The core stays small and predictable. Before adding to the core, ask whether it can be a client enhancer instead. Server-side transforms (parsing, the block model, the diff) belong in the core; presentation and optional behaviour should use the extension point below.
5.2 Conventions
- Rust edition 2024, workspace resolver 3. Shared dependencies are declared
once in the root
[workspace.dependencies]so versions stay centralized. - Directory-module pattern. When a file grows past ~1.5k lines it becomes a
directory module: a
mod.rsplus focused submodules that reach shared items viause super::*and expose what the parent needs aspub(super).render/andsite/are split this way. - The block contract. Anything you emit must keep
data-block-id(content hash) +data-sourcepos(anddata-source-filefor includes). Source mapping, the diff, and live-state preservation all key off it. rustfmt-clean,clippy-clean. APostToolUsehook formats every edited.rs. CI (.github/workflows/ci.yml) runs on every push tomainand every pull request:cargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace, the live-kernel suite, bothtsctype-checks, the companion’s tests,cargo audit,cargo deny check,build docs/{guide,internals} --check-onlyandtools/publish.sh --check..githooks/pre-pushruns the fmt, clippy, test, document and publish steps locally before a push that includesmain; it does not exist in a fresh clone. Locally,./tools/gates.shruns every gate in one process and reports success only if each one ran, because several skip silently without their interpreter.
5.3 The one extension point
Only one seam lets you add behaviour without editing the core.
Shortcodes are not an extension point. {{< name args >}} invocations expand to
inline HTML at pipeline stage 2 (render/extension/), but SHORTCODE_NAMES is a closed
vocabulary of two: {{< input … >}}, the reactive control that feeds the {js} graph,
and {{< include >}}, which an earlier pass resolves. Any other name is left verbatim
with a warning, so a typo draws a warning instead of shipping unnoticed. Adding a third
means editing the core.
Client enhancers are the extension point (window.taliEnhancers, in
code-enhance/). Your JS registers fn(root) that runs after every (re)mount; load it
with a <script defer src> written as raw HTML in the page. The built-in copy buttons
and mermaid.js use this same public API, so a third-party enhancer is
indistinguishable from core’s.
5.3.1 The client enhancer contract
An enhancer is a function(root) that decorates freshly-mounted DOM. It is the only
sanctioned way to add client-side behaviour. Its full contract:
- Register with
window.taliEnhancers.register(fn).fnreceives arootelement (the just-mounted subtree, ordocumentfor a whole-page run); scope your queries to it (root.querySelectorAll(...)), not todocument, so an incremental update only re-decorates the block that changed. - When it runs. The same
fnfires on three occasions: once on the initial full render (DOMContentLoadedin a static build, thefull_rendersnapshot in the live preview), again after every incremental block op (the client callstaliEnhancers.run(root)on the swapped-in DOM, so a re-rendered block is enhanced just like a fresh one), and immediately on registration if the page is already mounted (the case when a deferred script loads after the first mount). - Idempotency is mandatory. Because the same
fnre-runs on every change, it must do nothing the second time it sees a node. The convention is a markerdata-attribute: check for it, bail if present, set it once you’ve decorated. An enhancer that appends a button without this guard will stack a new button on every keystroke. - Failures are contained. Each enhancer runs inside a
try/catch; a throw is logged to the console ([taliesin] enhancer failed) and the other enhancers still run. An enhancer bug degrades one decoration and leaves the page rendered.
A minimal, idempotent enhancer that adds a “Run” affordance to every pre.shell block:
window..;
Ship that file next to the page and load it with a raw-HTML <script defer src> line in
the .tmd itself:
The build harvests every src= it can see, so the file is copied into the output beside
the page, and the enhancer is wired into the same mount cycle the built-ins use. To share
one across a project, put that line in an _includes/ partial and {{< include >}} it
from each page. _site.yml had a project-wide head: for this until 2026-08-18, when it
was cut at zero adoption.
5.4 The editor
An editor gets everything from taliesin lsp, an offline kernel-free language server
over stdio, so cmd = { "taliesin", "lsp" } is the entire setup in any LSP editor. The
VS Code companion in editor/vscode/ implements no language features of its own; it
adds only what the protocol has no concept of, which is the preview webview, the
source sync and the code-cell routing described below.
It answers six read-only capabilities:
| capability | what it gives the author |
|---|---|
completion | front matter, cell options, cross-references, citations, shortcodes and paths, wherever one is legal |
hover | a cross-reference’s label, a front-matter key’s docs, a citation’s BibTeX entry |
definition | an include’s file, an anchor’s definition site, a citation’s .bib entry |
documentSymbol | the heading outline |
codeAction | the “did you mean X” quick fix |
foldingRange | front matter, headings, ::: divs, code fences |
plus publishDiagnostics, pushed live as you type. Two more requests are Taliesin’s
own, namespaced so they can never collide with the protocol: taliesin/cellRegions
tells an editor which language owns a code cell’s range, so it can route completion,
hover, signature help and go-to-definition there, and taliesin/siteMap resolves a
page’s served URL (what lets a chapter preview open at the right page).
The VS Code companion does that routing in src/embedded.ts, because LSP has no way for
one server to hand a range to another. It mirrors the .tmd into a file of the cell’s
language, every line outside that language’s cells blanked so positions match one for
one, and asks that language’s own provider (Pylance, the TypeScript server) against it.
The file lives in a private temp directory and is only ever written on disk, never edited
as a buffer, so VS Code never sees it as unsaved and never shows it as a tab (an untitled
buffer, the first design, surfaced as a background Untitled-N tab). A definition that
lands in that file is moved back onto the .tmd, so go-to-definition never takes the
author into it. Every {js} cell runs as its own function, and taliesin/cellRegions
gives the editor that function’s first and last line, which go on the line above the cell’s
body (its fence or last option line) and on its closing fence: a name declared in another
{js} cell is out of scope there, as it is at run time, and tali, container and the
cell’s other arguments read as parameters. A cell whose fence is the document’s first line
and that has no //| option line stays unwrapped, because that line holds the comment that
keeps the file’s own diagnostics out of the Problems panel. Plain display fences (a bare
python or js info string) are kept too, unwrapped, although they never run, so a
definition can land on a display sample.
5.5 Byte-identity for refactors
A pure refactor must not change the output. The standard check is to build the
corpus before and after and diff the results:
This diff and the test suite are what make the larger module splits safe.