5 Extending Taliesin
Where a new feature belongs, which two seams are meant to be extended, and what a lean core deliberately rules out.
This chapter is for a reader checking whether the design holds together: the conventions the code follows, the two seams 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; power lives at the edges. 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 ride 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 is load-bearing. 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, and.githooks/pre-pushgates any push that includesmainoncargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace,build docs/{guide,internals} --check-onlyandtools/publish.sh --check. That hook is the only gate that runs automatically today, and it does not exist in a fresh clone. Run./tools/gates.shfor the rest:cargo audit,cargo deny check, thetsctype-checks and the live-kernel suite, because those skip silently without their interpreter and the script refuses to report success unless each one really ran.
5.3 The one extension point
There is exactly one seam that lets you add behaviour without editing the core, and being honest about that is the point of this section.
Shortcodes are not it. {{< 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 is visible rather than shipped as literal text. Adding a third means
editing the core, which is the opposite of an extension point.
Client enhancers are (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, and the contract is small enough to state in
full:
- 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, it does not blank the page.
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 and the
source sync.
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
there, and taliesin/siteMap resolves a page’s served URL (what lets a chapter
preview open at the right page).
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:
That, plus the test suite, is what makes the larger module splits safe.