Skip to content
Taliesin Internals

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.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 is load-bearing. 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, and .githooks/pre-push gates any push that includes main on cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, build docs/{guide,internals} --check-only and tools/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.sh for the rest: cargo audit, cargo deny check, the tsc type-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). 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, it does not blank the page.

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 and the source sync.

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 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:

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

That, plus the test suite, is what makes the larger module splits safe.