Skip to content
Taliesin User Guide

12 CLI reference

Every subcommand and flag: preview and build, the pre-publish lint gate, and the lsp editor server.

Reference: Cheat sheet · CLI · Configuration · Cell options · Troubleshooting · Accessibility · Licensing

CommandEffect
preview <file.tmd> [port]Live preview server (default port 4321). The document is served as the project it belongs to (the nearest ancestor _site.yml), opened at its page, so nav and cross-page links work; with no such ancestor it is served on its own
preview <dir>Live multi-page site/book preview (cross-page nav + per-page hot reload); the directory needs a _site.yml
build <file.tmd> [out.html]Write one HTML file (default <name>.html); the local files it references are copied beside out.html, and a different file already at one of those paths is never replaced (an error names it). A page of a project is finished as its project finishes it (its _site.yml, numbering and hero:) but without the project’s navigation or a listing:, since this build writes no other page: a link to another page, or a cross-page reference, is reported, and a listing is left out with a note
build <file.tmd> --out <dir>Write <dir>/index.html + copied assets (a portable folder); a referenced file above the document’s own folder has no place in it and is an error
build <file.tmd> --stdoutWrite the page to stdout instead of a file (single document only); add --no-exec for a static, kernel-free dump
build <dir> [--out <dir>]Build a multi-page site/book to _site/ / _book/; the directory needs a _site.yml
build <file.tmd|dir> --check-only [--strict] [--format json]Lint and write nothing: list every located diagnostic and exit non-zero on any error or warning (the CI / pre-publish gate)
init [dir]Scaffold a previewable project in dir (default .): a _site.yml, an index.tmd and one dated post under posts/, and nothing else; refuses to overwrite
lspRun a stdio LSP server for live .tmd diagnostics and editor intelligence in any LSP editor (Neovim, Helix, VS Code; Zed needs an extension). It does not provide syntax highlighting (see below)
doctor [dir] [--format human|json]Audit whether the environment can run code cells: which Python is resolved and why that one, whether ipykernel imports, and _site.yml validity. A status line per item with a fix command; exits non-zero only if a configured interpreter is broken

A <dir> target needs a _site.yml: that file’s presence is what makes a directory a project (see Configuration). Point preview or build at a directory that has none and the command refuses rather than guessing, and names two ways forward:

  error   corpus/agent has no _site.yml, so it is not a project.
          to build one document:   taliesin build corpus/agent/<page>.tmd
          to make it a site or book: add a _site.yml

Build (or preview) one document directly, or add a _site.yml to turn the directory into a project. If the directory instead sits inside an existing project, the message names that ancestor and the command to run there instead:

  error   corpus/tech-blog/posts has no _site.yml.
          its ancestor corpus/tech-blog is a project. did you mean:
            taliesin build corpus/tech-blog

preview reports the same two forms, with preview in place of build throughout.

12.1 --check-only: the pre-publish gate

taliesin build <file|dir> --check-only renders the document or project in memory, reports everything that flows through the diagnostics channel, and writes nothing (no _site/, .html file or _freeze/ entry). On a file it reports what build of that file would: a page of a project is judged as the one page that build writes, so its links to other pages are reported there, and build <dir> --check-only is the gate that judges it as part of its project. It never starts a kernel either, so --no-exec is implied, and it rejects --out / --stdout / --jobs, since each describes output it will not produce.

The check is static, and its clean verdict says so. A project with executable cells gets no static problems found · N code cells not run rather than a bare “no problems found”, because a gate that never starts a kernel cannot promise those cells run: the publish build is what proves that, and the two-stage taliesin build . --check-only && taliesin build . --strict below runs both.

It reports unknown front-matter / _site.yml / cell-option / container keys (with did-you-mean), a known cell option that does nothing on its cell, a cell label without a fig- / lst- / tbl- prefix, unknown callout kinds, broken @xref cross-references, unknown shortcodes, a missing bibliography:, duplicate heading {#id}s, and broken in-page #anchor links. It also catches these publish-blocking mistakes:

RuleFlags
Broken relative linkA [text](other.tmd) / [text](page.html#x) whose local target file is missing. In a site, links resolve against the page registry (.tmd→.html), so a cross-page anchor page.html#frag whose frag is no id on that page is flagged too. External http(s):// links are never checked: the lint stays offline and deterministic, and a cross-book / mounted link (one that climbs above the site root) is left alone.
Dangling reactive inputA {js} cell’s //| input: x that no {js} cell (//| name///| viewof), {{< input >}} control or Python define(x=...) keyword defines, with a did-you-mean over the known names. Only a define whose names the static read cannot know (a ** splat, a positional argument, or a call it cannot parse) suppresses the check, page-wide.
Reactive dependency cycleA loop among {js} cells (a consumes b, b consumes a), statically mirroring the runtime graph so none would run.
Heading-level skipA heading that jumps two or more levels deeper than the one before it (an h2 directly followed by an h4, skipping h3), which breaks the document outline for screen readers. Only a true mid-document skip is flagged (never “doesn’t start at h1”).
Missing or placeholder alt textA hand-written / passthrough <img> with no alt attribute, so a screen reader can’t describe it (a decorative image should set alt=""); or an alt that names the medium rather than the content (alt="image", or an echo of the filename). Markdown ![…](…) always emits an alt, so the first half catches raw <img> only.

Output is a greppable path:line: severity: message linter line by default, closed by a per-severity summary (3 problems (1 error, 2 warnings)); or a { "diagnostics": [{file, line, message, severity, col?, end_col?, suggestion?}] } object with --format json, which is the machine-readable surface for documents (doctor --format json is the other one, and it reports on your environment rather than your prose). It exits 0 when nothing that gates fired, so it drops straight into CI or a pre-commit hook.

Each human path is rooted on the target as you typed it, so it opens from wherever you ran the command: taliesin build docs/guide --check-only from the repository root reports docs/guide/using/recipes.tmd:12:, not a bare using/recipes.tmd that only resolves inside the project. (The file in --format json stays relative to the target, since whoever passed the target can resolve against it.)

taliesin build . --check-only            # fails (exit 1) on any error or warning
taliesin build . --check-only --strict   # …and on suggestions too
taliesin build . --check-only --format json > problems.json

12.2 What fails the run

There are three severities, so that advice about how a sentence reads does not carry the same weight in CI as a broken cross-reference.

SeverityMeansFails the run
errorThe document is wrong: a broken ref, a dead link, unparseable YAMLalways
warningSomething will silently not work: an unknown key, a missing altalways
suggestionAdvice you may decline. No check emits this today: the last one that did (a .bib entry nothing cites) was cut on 2026-08-20never, unless you pass --strict

A suggestion is always printed; only the exit code changes. build --strict uses the same rule, so advice can never block a release, and the summary says so rather than reporting it as a problem beside an exit 0.

One warning is exempt from that table: external reference not bundled, the offline-guarantee nudge a writing build prints when a page keeps a remote script, image, stylesheet or {js} import. Keeping one can be intentional, and the tool does not download URLs at build time, so the nudge is printed (and carried in --format json) but never fails the run, with or without --strict.

Unparseable YAML fails a plain build

A build without --check-only or --strict writes its output and exits 0 even when it reported problems: a dead link or a missing image ships, and the closing tally names --strict as the flag that would have failed it. Two exceptions fail the build unconditionally. One is a front-matter or _site.yml block that does not parse. Nothing in such a block is read, so the page would otherwise ship without the title:, bibliography: or listing: you wrote (and a site without its nav and its url:) while printing built and exiting 0. The other is a document with executable cells and no usable kernel; pass --no-exec to render them as source on purpose. In both cases the output is still written; only the exit code changes.

Severity is carried by the validator that found the defect, not derived from the wording of its message. A reworded message therefore cannot silently reclassify a family, and there is no TAL-* code catalogue to keep in step.

--format json stays valid JSON even when the lint itself can’t run: an unreadable or missing path (or an empty project) is reported as a single {"error": "…"} object on stdout (with a non-zero exit), so taliesin build x --check-only --format json | jq never chokes on a stray human message. Human format keeps that error on stderr.

The lint is static, so it does not spawn interpreters and says nothing about whether a kernel works. That audit is taliesin doctor, which resolves the interpreter each language would run on and probes it. See the configuration reference for how the interpreter is chosen.

Everything the lint looks at, grouped by what it protects:

CategoryCatches
Config typosUnknown front-matter, _site.yml, cell-option, and ::: container keys (with did-you-mean), unknown callout kinds, unknown shortcode names, YAML parse errors
Cross-referencesBroken @fig- / @sec- / @eq- / @lst- / @tbl- refs (no matching label), a citation with no bibliography:, a bare @key that renders as literal text
Links & anchorsBroken relative .tmd/.html links (and, in a site, cross-page links + cross-page #anchors), broken in-page #anchors, duplicate heading {#id}s
Local mediaA local image (an <img src>, a srcset candidate with <picture><source> included, a front-matter image:, or _site.yml’s logo:/favicon:) that is missing, or that the build cannot publish: outside the project, a symlink out of the checkout, or under a .-prefixed folder
Reactive {js}A //| input: naming nothing that’s defined, a dependency cycle among {js} cells
AccessibilityAn <img> with no alt or a placeholder one, a mid-document heading-level skip
BibliographyA duplicate key inside a .bib (the build silently keeps the last definition), an entry that is never closed, a key no [@…] citation can name, an undefined @string macro, a .bib that is not UTF-8, and a bibliography: path that does not resolve

Each row names a defect you cannot see in the rendered page. A check stays in this set only if it catches such a defect.

--strict also exists on a writing build, where it means the same thing one step further along: it turns a normally non-fatal build into a failure, so the page is still written but build exits non-zero if any located warning fired or any code cell errored at runtime. Advice is exempt there too, and so is the external reference not bundled nudge above. Because a writing build runs cells, --strict adds what a static lint structurally can’t see (a cell that throws) on top of the located diagnostics, which makes taliesin build . --check-only && taliesin build . --strict a two-stage CI gate: the lint fails fast and kernel-free on structure, then a strict build proves every cell runs.

--version / -V prints the version followed by the git commit it was built from, in parentheses:

Taliesin 0.2.0 (cdf0a13)

--help / -h (or no arguments) prints the top-level usage. Putting --help / -h after a subcommand (taliesin build --help, taliesin preview -h, …) prints a focused page for just that command: its synopsis, its flags, and a one-line example. If the default port (or one you pass) is already in use, what happens depends on who holds it. A preview of the same project is replaced, so re-running preview hands back the canonical URL instead of stacking a second server (every extra one would keep its own file watcher and kernel re-executing the same sources, on a port nobody is watching). A port held by anything else falls back to the next free port. Either way the chosen URL is logged.

Every command reads its arguments by one rule: a flag may appear anywhere on the line, --flag=value is the same as --flag value, an argument that starts with - is always a flag (spell a dash-named file ./-notes.tmd), and an argument beyond the ones a command takes is refused rather than ignored.

preview takes these flags (they may appear anywhere on the line):

FlagEffect
--openLaunch the default browser at the preview URL
--no-execRender code cells as source without executing them: no kernel is started, and a {js} cell is not handed to the browser either. It does not strip raw HTML (see below)
--port <N>Serve on port N. The same thing as the [port] positional, and it wins when both are given (--port=<N> works too)

12.2.1 Documents you did not write

Previewing a .tmd runs it, so opening a document someone sent you is the same kind of decision as running a script they sent you. Taliesin does not sandbox a document (see also SECURITY.md):

  • Code cells execute. {python} runs against a Jupyter kernel with your permissions; a {js} cell runs in your browser on the preview’s origin.
  • Raw HTML passes through, because Markdown says it does. That markup is inserted verbatim.
  • --no-exec stops the code cells, both kinds, and starts no kernel. It is not a sanitizer: raw <script> in the document body still reaches the page. There is deliberately no HTML sanitizer and no CSP, because a sanitizer that a document’s own author can work around buys a false sense of safety.
  • A --check-only lint never spawns an interpreter. The items above are documented; this one is enforced.

--no-exec is the right flag for skimming a stranger’s document; for trusting one, use a container or a throwaway user.

build takes these flags (they may appear anywhere on the line):

FlagEffect
--out <dir>Write a portable <dir>/index.html + copied assets instead of a single file
--strictExit non-zero if any cell errored or any located warning fired, except the external reference not bundled nudge (a CI gate; the page is still written)
--jobs <N> (alias -j)Maximum pages to build in parallel (default: auto, memory- and core-capped; --jobs 1 forces sequential; --jobs 0 same as auto)
--no-execRender code cells as source instead of running them. Without it, a build whose document has executable cells and no usable kernel fails, so this is how to ask for source-only output on purpose
--format jsonEmit {diagnostics: […]} to stdout instead of only the log. It is not exclusive to --check-only: a writing build reports the same way

A build already content-gates the optional enhancer scripts (mermaid, {js}) to the constructs a page uses, so a prose page ships less JavaScript without losing any feature.

12.3 Iterating on code with preview

Leave taliesin preview running while you write Python or ML code in a document. On each save, only the cell you edited and the ones downstream of it re-execute, against a kernel that stays warm between saves. The kernel’s variables survive, so a four-gigabyte load in cell 3 is paid once and re-running one cell does not re-run the expensive ones above it.

12.3.1 Publishing after a preview

The preview writes _freeze/ from a fresh kernel only, which means a page’s first run and the run after Restart kernel. A re-run inside a warm kernel is not written, because that kernel still holds names from earlier versions of your cells: rename threshold to limit and a leftover print(threshold) keeps printing there, while a fresh kernel raises NameError. So the first build after an editing session re-runs from the earliest cell you edited, and later builds replay:

taliesin build .
#   exec  index.tmd  restored 3 cached cells · 0 re-ran

The cache is keyed on the cell’s own code plus every upstream cell’s, so there is nothing to clear by hand: no edit to code can produce a stale hit. The key does not see what a cell reads (a data file, an environment variable, an upgraded library), so mark a cell with an out-of-band input #| cache: false.

12.3.2 One kernel per project

One preview per project means one kernel set and one _freeze/ writer, so two views of a document cannot disagree about what a cell produced. A second preview of a project that is already open stops the running preview and takes over its port rather than starting a second one.

12.3.3 Top-down runs

A rebuild runs from the top: it re-executes whatever the kernel is missing before the cell you changed, then that cell, then everything downstream. It never runs a cell in isolation against whatever state happens to be lying around, which is the hidden-state bug that makes notebooks irreproducible.

12.4 Portable, offline builds

A --out <dir> build (single doc, site, or book) is a self-contained folder: open index.html straight from disk (file://), or zip the folder and send it. Everything the reader needs is copied in and works with no server and no network: navigation and prev/next, the Cmd-K search across the whole book, all math (rendered to KaTeX at build time; the fonts ship inside the folder), the table of contents and its scrollspy, and any {js} cells (the d3 / Plot runtimes are vendored, not fetched). Click-to-source is not shipped into a build (it belongs to the live preview), so a reader never gets a dead outline.

Mermaid diagrams are offline too. The Mermaid runtime is vendored, never fetched: a --out <dir> folder carries it as a file inside the folder (mermaid.min.js beside the page for a single document, one shared _assets/mermaid.<hash>.js for a site or a book), written only when something in the build has a diagram. It is 3.5 MB, which is why it goes beside the page rather than into it.

The one build that does inline it is taliesin build doc.tmd, which produces a single HTML file and has nowhere else to put it: expect that file to be large if it has a diagram. (The live preview serves the same vendored copy from a same-origin route, so the dev loop is offline without adding 3.5 MB to every page it re-serves.)

Nesting one project inside another’s output composes two builds into one deploy: build the book with --out pointed inside the site’s own output directory, at whatever path you want it to live:

taliesin build my-site                          # -> my-site/_site/
taliesin build docs --out my-site/_site/docs    # the book, nested at /docs

Build the parent first. A build sweeps whatever sits under its --out target that it did not itself write, so a book built before the site would be swept away by the site’s own build. The parent cannot see the nested project’s pages, so any link it writes into the nested prefix is reported as broken; resolve such links yourself against the built output.

Prefer separate deploys where your host allows them. Nesting keeps every page under one domain, and costs you the checks: the parent can no longer verify the nested links, and preview serves the parent alone, so every link into a nested project 404s while you write. Publishing each project to its own domain and linking between them with absolute URLs avoids both, and on hosts that deploy a directory as a whole site (Cloudflare Pages, Netlify, GitHub Pages) it is also the only way to publish one project without re-uploading the others. Taliesin’s own four sites are published that way; none of them nests.

Set url: in _site.yml before publishing to a real domain. The absolute-URL og:url / og:image tags, sitemap.xml, robots.txt, and the Atom feeds are only emitted when a project url: is configured. Without it a page’s image: still shows on its listing card, and no og:image is published.

12.5 Starting a project: init

init scaffolds a whole project, which is three files: a _site.yml holding the title, a hello-world index.tmd whose listing: collects posts/, and one dated post in it.

taliesin init my-site   # _site.yml + index.tmd + posts/my-first-post/index.tmd

init offers no choice of starter, and everything after it is an edit to what you have: write your next post by copying posts/my-first-post/, add a page by dropping another .tmd beside index.tmd, add a top nav with a nav: block, and turn the project into a book by listing the pages under chapters:. See Configuration.

The scaffold is correct on its first save: it renders, and build --check-only passes on it with no diagnostics. A test enforces this: crates/server/tests/init_cli.rs runs the real binary and then the real lint over what it wrote, so a front-matter key the validator would reject cannot survive in the scaffold. Like every write this tool makes, init refuses to overwrite an existing file.

12.6 Checking the environment: doctor

doctor checks whether this machine can run your code cells. It resolves the interpreter each language would run on, probes it, and validates _site.yml, all without rendering anything.

taliesin doctor  ·  is your environment ready to run code cells?

  ✓  python  /home/me/proj/.venv/bin/python (.venv)
             Python 3.12.3  ·  ipykernel present
  ✓  config  _site.yml is valid

  python cells will execute.

A failure names the interpreter it found, why that one, and the command that fixes it:

  ⚠  python  python3 (python3)
             Python 3.12.3  ·  ipykernel MISSING
             .venv search: searched /home/me/proj, /home/me; stopped at /home/me
             fix: python3 -m venv .venv && .venv/bin/pip install ipykernel

The line in parentheses is where the interpreter came from, and the search is ordered. First match wins:

  1. python: in _site.yml, resolved against the directory holding that file.
  2. A .venv/bin/python (then python3) in the project directory itself.
  3. TALIESIN_PYTHON.
  4. The nearest .venv in an ancestor directory, walking up.
  5. python3 on PATH.

A project .venv therefore outranks TALIESIN_PYTHON while an ancestor one does not, which is why the trail line reports where the walk looked and where it stopped: the common surprise is a venv one directory too far up. --format json prints the same findings as an object for a script to read, and the exit code is narrow: non-zero only when an interpreter you configured is broken, because a machine with no project venv is a fact worth reporting, not a failure.

doctor runs the interpreter it finds. Probing means running it twice, with --version and with an import ipykernel check, and the interpreter can be a program the project itself names in python: or ships as .venv/bin/python. On a project you did not write, running doctor is running that program; build --check-only, --no-exec and taliesin lsp start no interpreter.

12.7 Inspecting a document: build --stdout

build --stdout shows a document in a terminal instead of a browser, for piping, diffing, and debugging.

It writes one complete HTML page to stdout instead of to a file (a local image stays a relative reference, and nothing is copied), so you can redirect or pipe it:

taliesin build post.tmd --stdout > post.html
taliesin build post.tmd --stdout | grep -c data-block-id   # count emitted blocks

It is an ordinary build, so it runs the document’s code cells. Add --no-exec for the static dump: highlighted source with an empty output block, no plots, and any @fig- reference to a computed figure left dangling:

taliesin build post.tmd --stdout --no-exec > post.html

--no-exec covers {js} cells too: a reactive cell is a code cell whose runtime is the browser rather than a kernel, so it is rendered as highlighted source and never handed to the page.

The page carries the same data-block-id + data-sourcepos attributes that drive click-to-source and incremental updates, so this is the way to confirm a block carries the source map you expect (and, via data-source-file, which {{< include >}}d file it came from) without opening the browser dev tools.

preview binds 127.0.0.1 and nothing else: there is no flag that exposes it on a network. Every response passes a Host allowlist (the DNS-rebinding guard) and the websocket enforces an origin check, so a page on another site cannot drive it. A page on another site, or on another local port, can link to the preview but cannot load its responses into itself: the search index holds the text of every page, drafts included.

Put target/release/taliesin on your PATH (a symlink will do) so taliesin preview <file> works from anywhere.

12.8 taliesin lsp: the editor server

lsp runs a local, offline stdio LSP server (Language Server Protocol) so an LSP editor gets live .tmd diagnostics as you type, the same validators the gate runs, on the unsaved buffer, plus completion, hover, go-to-definition, symbols, quick fixes and folding. All six capabilities are read-only: the server is parse-only (no kernel, no code execution) and it never edits your source; your editor does.

-- Neovim 0.10 or later: init.lua. Neovim does not know `.tmd`, so map it to Markdown
-- (which also gives the highlighting) and start the server for `.tmd` buffers only.
vim.filetype.add({ extension = { tmd = "markdown" } })
vim.api.nvim_create_autocmd("FileType", {
  pattern = "markdown",
  callback = function(args)
    if not vim.endswith(args.file, ".tmd") then return end
    vim.lsp.start({
      name = "taliesin",
      cmd = { "taliesin", "lsp" },
      root_dir = vim.fs.root(args.buf, { "_site.yml", ".git" }),
    })
  end,
})
# Helix: ~/.config/helix/languages.toml
[language-server.taliesin]
command = "taliesin"
args = ["lsp"]

# `.tmd` is Markdown plus extensions, so attaching the server to Helix's own
# markdown language is both the highlighting answer and the LSP answer. `file-types`
# REPLACES the built-in list rather than adding to it, so keep the defaults.
[[language]]
name = "markdown"
file-types = ["md", "markdown", "tmd"]
language-servers = ["taliesin", "marksman"]

12.9 Other LSP editors

The server speaks LSP, so the intelligence above works anywhere. Syntax highlighting does not travel with it, and outside VS Code a .tmd document can look grey:

  • Taliesin ships no tree-sitter grammar. The only .tmd grammar in the repository is the TextMate one inside the VS Code companion, which is a VS Code format.
  • The server answers textDocument/semanticTokens with “method not found”. Semantic tokens would not fix this anyway: Helix does not implement them, and Zed ships them off.

So outside VS Code, point your editor at Markdown for the colours (as the Helix snippet above does) and let the LSP supply everything Markdown cannot know. You lose highlighting on the .tmd-specific syntax (::: {.callout-note} fences, #| cell options, [@citekey], @fig- references) and keep it for the Markdown the rest of the document is.

Zed needs an extension, and there is not one yet. Zed’s lsp setting re-points a language server it already knows about; registering a new one is a published extension, not a config block. You can still get highlighting today with "file_types": { "Markdown": ["tmd"] } in settings.json.

Diagnostics are pushed, and they cover the buffers you have open. The server publishes a document’s findings as you type. For the whole book at once, including the chapters no editor has opened, run the gate:

taliesin build . --check-only

which is where the same findings are already checked before publishing.

12.10 Environment

VariablePurpose
TALIESIN_PYTHONPython with ipykernel for {python} cells (default python3)
TALIESIN_CELL_SILENCEHow long a cell may produce no output before it is interrupted, in seconds (default 600; 0 disables). This is the default liveness cap: the budget resets on every line a cell prints, so a long job that reports progress is never interrupted, while a wedged one still is
TALIESIN_CELL_TIMEOUTOptional per-cell wall-clock cap in seconds, off by default. Set it to bound a cell’s total runtime regardless of how much it prints (0 disables). A runaway that keeps printing new lines is already caught by the output caps. One that only redraws a line with \r is not, since it reads as a long job reporting progress; this cap bounds that case too
TALIESIN_RENDER_TIMEOUTPer-render wall-clock cap in seconds (default 30; 0 disables it). Rendering is not execution: it never legitimately takes seconds, so this is a watchdog against a pathological document, not a budget to tune. A render that exceeds it is abandoned with a located diagnostic instead of hanging the preview
TALIESIN_NO_EXECSame as --no-exec (never execute code cells)
TALIESIN_NO_CACHEIgnore and skip writing the _freeze/ execution cache
TALIESIN_NO_CLEARDon’t clear the terminal on each render (keep scrollback)
NO_COLORAny value disables ANSI colour in the console output of every verb; colour is off anyway when the stream is not a terminal
TALIESIN_MERMAID_URLPoint the Mermaid loader at a different copy of the library. Builds and previews are already offline without it (the library is vendored and either inlined, written into the output folder, or served from a same-origin route), so this is for pinning a specific Mermaid version or serving your own copy; on load failure a visible [data-mermaid-error] banner shows the diagram source

Without a kernel, cells render as highlighted source and the preview shows a “kernel unavailable” diagnostic rather than failing.