Spice version 0.3.0 -- Literate
.tur.mdnotebooks with a static renderer and an interactive terminal TUI. Audience: Turmeric users who want a Jupyter-style workflow for exploratory code, reproducible analyses, and shareable HTML reports.A
.tur.mdfile is a strict superset of CommonMark: ordinary markdown that renders cleanly in GitHub / VS Code / Obsidian / pandoc, where fenced code blocks taggedturmericorsweet-expare executable cells. Pair withtur-framefor data loading,tur-plotfor figures, ortur-statsfor analysis.
This guide walks through the workflow:
.tur.mdIn your project's build.tur:
:spices #{
"notebook" #{:url "https://github.com/turmeric-lang/turmeric-spices"
:ref "notebook-v0.1.0"
:subdir "spices/notebook"}
}
Then:
tur fetch
tur install tur-notebook ;; puts `tur-nb` on $PATH
No external C dependencies -- the parser, renderer, and TUI are pure Turmeric plus a vendored libturi for in-process cell execution.
The spice needs turmeric v0.66.0 or later (:tur-version ">=0.66.0"), the
first release whose tur/markdown has the GFM extensions, math and
per-block HTML the notebook parses and renders its prose with.
Scaffold a starter file:
tur nb new analysis.tur.md
Open it in any editor. The body looks like ordinary markdown -- prose, headings,
lists -- except that fenced turmeric blocks are cells:
# My Analysis
Some prose explaining what we're about to do.
```turmeric
(+ 1 2)
```
More prose.
```turmeric {id=greeting}
(println "hello, notebook")
```
Cells have optional attributes inside {...} on the fence line (Quarto
style). The most common:
| Attribute | Default | Meaning |
|---|---|---|
id |
auto (cell-1, cell-2, ...) |
Stable handle for --cell and TUI navigation |
eval |
true |
Set false to render without executing |
echo |
true |
Set false to hide the source in rendered output |
output |
true |
Set false to suppress the output block |
error |
halt |
continue records the error and proceeds |
cache |
false |
Cache by source hash under .turnb-cache/ |
depends |
(none) | Comma-separated cell ids this cell depends on |
image |
inline |
inline = base64 in rendered file; file = sibling PNG |
Cells tagged sweet-exp use sweet-expression syntax -- everything else
about the workflow is identical.
All cells in a notebook share one libturi session, so an (import ...)
in one cell makes the module available in every subsequent cell -- exactly
like a Jupyter kernel. This is what makes cross-spice workflows possible:
load data with tur-frame in one cell, fit a model with tur-stats in
the next, plot the result with tur-plot in a third.
The spices most useful in notebooks:
| Spice | Import | What it provides |
|---|---|---|
plot |
(import plot/core :refer [plot-write-png]) |
2D visualization, PNG output |
linalg |
(import linalg/mat :refer [mat-of mat-mul]) |
Dense linear algebra |
stats |
(import stats/dist :refer [dnorm pnorm]) |
Distributions, tests, regression |
frame |
(import frame/csv :refer [read-csv]) |
Data frames, CSV I/O |
These spices must be declared in your project's build.tur (or the
notebook's own build.tur as :optional true deps) so tur fetch makes
them available. Without the declaration, the import fails with a clear
error -- the spice is not installed.
Imports resolve the way tur run <notebook> would for a program at the
notebook's path: first the notebook's own directory (a helpers.tur next to
it is (import helpers ...)), then the enclosing spice's src/, each
:spices dep's src/, and the other members of the workspace -- whatever
directory you render from. The session starts from the same stdlib
tur --interpret gives a program.
Spices written in C run compiled. Cells run in the interpreter, which
does not run inline-C bodies. So before a cell evaluates, the notebook
builds each spice the cell imports as a shared library -- once, with
tur build --shared, cached under that spice's .tur-repl-cache/ and
rebuilt when a source changes -- and calls its exports there, the way
tur repl does for the spice it starts in. The first cell that imports a
spice takes a few seconds longer; set TUR_BIN if tur is not on PATH.
What runs compiled:
stats (distributions, tests, summaries), frame (CSV, filtering,
group-by, printing), plot (scatter, histograms, densities) and linalg
(matrices, solvers, formatting), whose APIs pass numbers, strings,
handles, cons lists and by-value records.A function that takes or returns a record (linalg's mat) is callable once
its module is imported in the session -- the import is where its layout
comes from -- which a cell does anyway to use the name.
What still cannot:
defn passed as a C callback, such as plot's function
renderer: the interpreter's closure has no C address to hand over.defn written in a cell itself, beyond the interpreter's
few simple shapes. Build such values with cons and float->bits
((load "stdlib/bits.tur")) instead -- the example notebooks do.Tracked as turmeric's
docs/reported/notebook-cells-cannot-call-inline-c-spices.md.
Example -- load a CSV and print its shape:
(import frame/csv :refer [read-csv-string])
(import frame/frame :refer [frame-nrows frame-ncols frame-head])
(import frame/print :refer [print-frame])
(def df (read-csv-string "x,y\n1,2\n2,4\n3,6\n" 0 0 1 0 ""))
(println (frame-nrows df)) ;; rows
(print-frame (frame-head df 3))
See the frame guide for the full tur-frame API, the
linalg guide for linear algebra, and the example
notebooks in spices/notebook/examples/ for end-to-end workflows.
Render to markdown (the default):
tur nb render analysis.tur.md # writes analysis.md
Render to a standalone HTML page:
tur nb render analysis.tur.md --to html # writes analysis.html
tur nb export is a more discoverable alias for the same workflow:
tur nb export md analysis.tur.md
tur nb export html analysis.tur.md
tur nb export html analysis.tur.md --out site/ # write into a directory
tur nb export md analysis.tur.md --no-output # strip output blocks
tur nb export md analysis.tur.md --no-source # outputs only
For "I'm writing prose, just keep the rendered file fresh," watch mode re-renders on every save:
tur nb render analysis.tur.md --watch
Watch mode uses kqueue on macOS and inotify on Linux. Each re-render
starts with a fresh session -- predictability over warm caches. If you want
warm-cache exploration, use the TUI instead.
Prose is parsed and rendered by the stdlib's tur/markdown: all of
CommonMark, GitHub's tables, strikethrough, task lists and bare-URL links,
and TeX math. The HTML is what GitHub would show, and writing a notebook back
out (the TUI's save, markdown export) keeps each prose block exactly as you
wrote it.
Math is $...$ in a line and $$...$$ for a display formula, either in a
line or as a block: a $$ line, the formula's lines, and a closing $$
line. The HTML page renders it with KaTeX.
$ of an inline formula has no space or digit after it, so
$5 and $10 stays text. Write a formula that starts with a digit in
braces: ${2^n}$.$ has no space before it, and a formula stays on one line.$$ line that is never closed is ordinary text.\$ is a literal dollar sign.tur nb tui analysis.tur.md
The TUI is modal, in the Jupyter / vim style. In command mode, single keys navigate and re-run cells:
| Key | Action |
|---|---|
j / k |
Move focus down / up |
gg / G |
Jump to first / last cell |
Enter |
Re-run the focused cell |
Shift-Enter |
Run focused cell, then move to the next |
R |
Restart session and re-run all |
r |
Re-run from the focused cell onward |
e |
Edit the focused cell ($EDITOR) |
a / b |
Insert a new cell above / below |
dd / p |
Delete (yank) / paste a cell |
o |
Toggle output visibility |
s |
Save the file |
/, n, N |
Search across cell sources and outputs |
? |
Help overlay |
q |
Quit (prompts if dirty) |
Hitting e writes the focused cell to a temp file and spawns $EDITOR on
it; on exit, the cell source is replaced and the file marked dirty. The TUI
does not ship its own text editor -- you get the keybindings, theme, and
plugins you have already configured for vim / helix / nano / emacs / VS Code.
The interpreter session lives for the lifetime of the TUI process: definitions
made in one cell are visible in later ones, exactly like a Jupyter kernel.
R is the "clean slate" key when you want to verify a notebook runs from a
fresh session.
For cells that are slow to recompute (loading a large CSV, fitting a model), opt in to source-hash caching:
```turmeric {id=load-data cache=true}
(import frame/csv :refer [read-csv default-csv-opts])
(def iris (read-csv "iris.csv" (default-csv-opts)))
```
```turmeric {id=fit-model cache=true depends=load-data}
(def model (fit iris))
```
The cache key is SHA-256(cell-source + sorted-attrs + dependency-hashes).
Editing load-data busts every downstream cell that lists it in depends.
Pass --cache to enable the cache for render / export:
tur nb render analysis.tur.md --cache
The cache lives in .turnb-cache/ beside the source file -- add it to
.gitignore.
tur-notebook does not require any plotting library. To embed an image, a
cell writes a PNG and announces its path via the image hook:
(import notebook/image :refer [image-hook-record-path])
(import plot/core :refer [plot-write-png])
(plot-write-png renderers opts "iris-scatter.png")
(image-hook-record-path "iris-scatter.png")
The hook works via a stdout marker (__NB_IMG__: <path>) that the session
intercepts before display. Cells get the path back as part of their
cell-output.image-paths list, and the TUI / renderers do the right thing
with it:
<img src=...> link when image=file is set on the cell).
tag, so the rendered .md is self-contained.[image: path] placeholder; the file is still on
disk and openable from the shell.This is the same opt-in pattern tur-plot and tur-plutovg use: cells that
write PNGs can advertise them, but the spices themselves stay independent of
the notebook tooling.
The notebook format shines when a workflow spans multiple spices. Here is
a condensed end-to-end analysis: load a CSV with tur-frame, compute
summary statistics with tur-stats, fit a regression, and plot the data
and the fitted line with tur-plot. The full version lives in
spices/notebook/examples/data-analysis.tur.md.
Load the data:
(import frame/csv :refer [read-csv-string])
(import frame/frame :refer [frame-nrows frame-head])
(import frame/print :refer [print-frame])
(def csv "x,y\n1,2.1\n2,3.9\n3,6.2\n4,8.1\n5,9.8\n")
(def df (read-csv-string csv 0 0 1 0 ""))
(print-frame (frame-head df 5))
Summarize and fit:
(import frame/frame :refer [frame-column])
(import stats/summary :refer [col-mean col-sd])
(import stats/regress :refer [ols-frame])
(import stats/fmt :refer [print-fit])
(def y-col (frame-column df "y"))
(println (col-mean y-col)) ;; mean
(println (col-sd y-col)) ;; sd
;; ols-frame returns a result -- (0 . (fit)) on success; head/tail unwrap it.
(def fit (head (tail (ols-frame df "y" (cons (:: "x" :int) 0) 1))))
(print-fit fit)
Plot the data and the fitted line (see Plots and images for the two-call pattern):
(import plot/core :refer [plot-write-png])
(import plot/point :refer [points])
(import plot/line :refer [function])
(import plot/decor :refer [axes tick-grid])
(import plot/style :refer [default-line-style default-point-style default-plot-opts])
(import notebook/image :refer [image-hook-record-path])
;; ... build scatter data from the frame, then:
(plot-write-png
(vec-of (tick-grid)
(axes)
(points scatter-data (default-point-style) "observed")
(function fit-line 0.0 10.0 128
(default-line-style) "fitted"))
(default-plot-opts)
"/tmp/nb-analysis.png")
(image-hook-record-path "/tmp/nb-analysis.png")
Each step is a cell, the prose explains the analysis, and tur nb export html
produces a shareable report with the plot embedded as a base64 data URL.
Notebooks that use randomness (any cell calling into tur-stats's rng-*
or any PRNG) should pass an explicit seed. The notebook tooling does
not auto-seed -- doing so would make notebooks that look reproducible
silently non-reproducible the moment they are edited. Spell the seed in
user code:
(import stats/rng :refer [rng-make])
(def rng (rng-make 42))
For CI, the exec subcommand runs cells without writing output blocks back
to disk:
tur nb exec analysis.tur.md --all # run every cell, print outputs
tur nb exec analysis.tur.md --cell fit-model # run from this cell onward
A non-zero exit code means at least one cell errored, so this composes
cleanly with set -e in a CI script:
#!/bin/sh
set -e
for nb in notebooks/*.tur.md; do
tur nb exec "$nb" --all >/dev/null
done
Combine with deterministic seeds and you can diff notebook outputs in version control to catch regressions in numerical behavior.
The TUI's defaults live in notebook/keys.tur. Override them with a
file passed to --keybindings:
# ~/.turnb-keys
# one "key action" per line; # starts a comment
j cell-next
k cell-prev
<Enter> run-cell
e edit-cell
R restart-and-run-all
q quit
tur nb tui analysis.tur.md --keybindings ~/.turnb-keys
User bindings are merged onto the built-in defaults; only the actions you
list are overridden, so a minimal file is fine. The available actions are
documented in notebook/keys.tur (default-keybindings).
--no-color disables ANSI colors entirely -- useful for terminals that do
not handle 256-color escapes well, or for screen-readers.
[image: path] placeholder
in the TUI. Rendered HTML and markdown carry the image regardless.Prose is full CommonMark and GFM since 0.3.0, when the notebook's own
subset parser gave way to the stdlib's tur/markdown
(markdown-library-plan M5).