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
| Command | Effect |
|---|---|
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> --stdout | Write 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 |
lsp | Run 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:
| Rule | Flags |
|---|---|
| Broken relative link | A [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 input | A {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 cycle | A loop among {js} cells (a consumes b, b consumes a), statically mirroring the runtime graph so none would run. |
| Heading-level skip | A 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 text | A 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.)
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.
| Severity | Means | Fails the run |
|---|---|---|
error | The document is wrong: a broken ref, a dead link, unparseable YAML | always |
warning | Something will silently not work: an unknown key, a missing alt | always |
suggestion | Advice you may decline. No check emits this today: the last one that did (a .bib entry nothing cites) was cut on 2026-08-20 | never, 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.
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:
| Category | Catches |
|---|---|
| Config typos | Unknown front-matter, _site.yml, cell-option, and ::: container keys (with did-you-mean), unknown callout kinds, unknown shortcode names, YAML parse errors |
| Cross-references | Broken @fig- / @sec- / @eq- / @lst- / @tbl- refs (no matching label), a citation with no bibliography:, a bare @key that renders as literal text |
| Links & anchors | Broken relative .tmd/.html links (and, in a site, cross-page links + cross-page #anchors), broken in-page #anchors, duplicate heading {#id}s |
| Local media | A 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 |
| Accessibility | An <img> with no alt or a placeholder one, a mid-document heading-level skip |
| Bibliography | A 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):
| Flag | Effect |
|---|---|
--open | Launch the default browser at the preview URL |
--no-exec | Render 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-execstops 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-onlylint 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):
| Flag | Effect |
|---|---|
--out <dir> | Write a portable <dir>/index.html + copied assets instead of a single file |
--strict | Exit 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-exec | Render 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 json | Emit {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:
# 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:
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.
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:
python:in_site.yml, resolved against the directory holding that file.- A
.venv/bin/python(thenpython3) in the project directory itself. TALIESIN_PYTHON.- The nearest
.venvin an ancestor directory, walking up. python3onPATH.
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:
|
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:
--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
[]
= "taliesin"
= ["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.
[[]]
= "markdown"
= ["md", "markdown", "tmd"]
= ["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
.tmdgrammar in the repository is the TextMate one inside the VS Code companion, which is a VS Code format. - The server answers
textDocument/semanticTokenswith “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:
which is where the same findings are already checked before publishing.
12.10 Environment
| Variable | Purpose |
|---|---|
TALIESIN_PYTHON | Python with ipykernel for {python} cells (default python3) |
TALIESIN_CELL_SILENCE | How 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_TIMEOUT | Optional 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_TIMEOUT | Per-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_EXEC | Same as --no-exec (never execute code cells) |
TALIESIN_NO_CACHE | Ignore and skip writing the _freeze/ execution cache |
TALIESIN_NO_CLEAR | Don’t clear the terminal on each render (keep scrollback) |
NO_COLOR | Any value disables ANSI colour in the console output of every verb; colour is off anyway when the stream is not a terminal |
TALIESIN_MERMAID_URL | Point 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.