Skip to content
Taliesin Internals

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.rs plus focused submodules that reach shared items via use super::* and expose what the parent needs as pub(super). render/ and site/ are split this way.
  • The block contract. Anything you emit must keep data-block-id (content hash) + data-sourcepos (and data-source-file for includes). Source mapping, the diff, and live-state preservation all key off it.
  • rustfmt-clean, clippy-clean. A PostToolUse hook formats every edited .rs. CI (.github/workflows/ci.yml) runs on every push to main and every pull request: cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, the live-kernel suite, both tsc type-checks, the companion’s tests, cargo audit, cargo deny check, build docs/{guide,internals} --check-only and tools/publish.sh --check. .githooks/pre-push runs the fmt, clippy, test, document and publish steps locally before a push that includes main; it does not exist in a fresh clone. Locally, ./tools/gates.sh runs 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). fn receives a root element (the just-mounted subtree, or document for a whole-page run); scope your queries to it (root.querySelectorAll(...)), not to document, so an incremental update only re-decorates the block that changed.
  • When it runs. The same fn fires on three occasions: once on the initial full render (DOMContentLoaded in a static build, the full_render snapshot in the live preview), again after every incremental block op (the client calls taliEnhancers.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 fn re-runs on every change, it must do nothing the second time it sees a node. The convention is a marker data- 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.taliEnhancers.register(function (root) {
  root.querySelectorAll('pre.shell:not([data-ran])').forEach(function (pre) {
    pre.dataset.ran = '1';                 // guard: only decorate once
    var btn = document.createElement('button');
    btn.textContent = 'Run';
    btn.addEventListener('click', function () { /* ... */ });
    pre.appendChild(btn);
  });
});

Ship that file next to the page and load it with a raw-HTML <script defer src> line in the .tmd itself:

<script defer src="my-enhancer.js"></script>

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:

capabilitywhat it gives the author
completionfront matter, cell options, cross-references, citations, shortcodes and paths, wherever one is legal
hovera cross-reference’s label, a front-matter key’s docs, a citation’s BibTeX entry
definitionan include’s file, an anchor’s definition site, a citation’s .bib entry
documentSymbolthe heading outline
codeActionthe “did you mean X” quick fix
foldingRangefront 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:

taliesin build corpus/tech-blog --out /tmp/before/site   # on the old tree
taliesin build corpus/tech-blog --out /tmp/after/site    # on the new tree
diff -rq /tmp/before /tmp/after                          # must be empty

This diff and the test suite are what make the larger module splits safe.