flowchart LR qmd[.qmd file] --> quarto[Quarto] quarto --> pandoc[Pandoc] pandoc -- AST --> lua[Lua filter] lua --> nrepl[Jank nREPL server] nrepl --> lua lua -- AST --> pandoc pandoc --> out[HTML]
5 How It Works
This chapter explains the internals of Janqua — how your {.clojure .jank} code blocks turn into rendered output.
5.1 Architecture overview
Janqua is a Pandoc Lua filter. When Quarto renders a .qmd file, it calls Pandoc under the hood. Pandoc reads the document, builds an abstract syntax tree (AST), and then runs any filters specified in the frontmatter. The Janqua filter intercepts code blocks marked with the .jank class (typically written as {.clojure .jank} for editor syntax highlighting), evaluates them via a Jank nREPL server, and replaces them with the appropriate output.
The filter has two phases, executed in order:
Meta— reads document metadata and resolves the Jank nREPL portCodeBlock— processes each{.clojure .jank}code block
5.2 Port discovery
The filter needs to find a running Jank nREPL server. It tries several sources in order, probing each candidate port with a real nREPL evaluation before using it — a plain TCP-connect check would falsely accept a stale port that the OS had recycled to an unrelated service:
- Frontmatter — an explicit
jank.portvalue in the document metadata - PID + port files —
.jank-pidand.jank-nrepl-portwritten by the lifecycle script - Environment variable —
JANK_PORT - Process scan — searches for a
jankprocess listening on a TCP port (vialsoforss). Note: if multiple projects run Jank simultaneously, this step could find the wrong one — steps 1–3 are project-specific and preferred. - Auto-start — if nothing is found, the filter launches
jank replvia the lifecycle script and waits for it to be ready
This chain means zero configuration for the common case: just run quarto render and the filter handles the rest.
Auto-start fires loud: the filter prints a framed stderr block listing the new process’s PID, port, log path, and the exact stop command, so the side effect is never silent. Authors who prefer manual control can disable auto-start by setting JANK_AUTO_START=0 or by adding jank: { auto-start: false } to the document frontmatter; the filter then prints a “no nREPL found” notice and skips evaluation rather than spawning a process.
5.3 Process management
Jank’s REPL exits when its standard input closes. To keep it alive across renders, the lifecycle script (jank-lifecycle.sh) pipes tail -f /dev/null into the REPL’s stdin — an infinite stream of nothing that keeps the process running.
The lifecycle script manages three files in the rendering directory (your project root if you have a _quarto.yml, otherwise the directory containing the document being rendered — the filter asks Quarto where to anchor):
| File | Purpose |
|---|---|
.jank-pid |
PID of the running Jank process |
.jank-nrepl-port |
The nREPL port Jank is listening on |
.jank-repl.log |
Jank’s stdout/stderr output |
A background monitor watches the Jank process and cleans up these files if it crashes. It verifies the PID in the file still matches before deleting, to avoid removing a newer instance’s files if Jank was restarted quickly.
The stop command verifies the PID still belongs to a Jank process before sending a kill signal — checking both the process name and command line arguments. This prevents accidentally killing an unrelated process that reused the PID. If no PID file exists, stop refuses to guess and asks the user to kill manually.
5.4 Code evaluation
Each {.clojure .jank} code block goes through these steps:
5.4.1 1. Kindly wrapping
The user’s code is wrapped in a function call that captures the result and its metadata:
;; User writes:
^:kind/hiccup [:div "hello"]
;; Filter sends to Jank:
((fn [v]
((fn [m]
((fn [kind]
{:janqua/kind kind
:janqua/value (pr-str v)})
(when m (or (:kindly/kind m) ...))))
(meta v)))
(do ^:kind/hiccup [:div "hello"]))The wrapper uses nested fn calls rather than let bindings — this works around a Jank bug where reader-attached metadata (like ^:kind/hiccup) is lost through let bindings.
5.4.2 2. nREPL communication
The wrapped code is sent to Jank via clj-nrepl-eval, a Babashka-based nREPL client. The filter parses the response to extract:
- stdout — anything printed during evaluation
- value — the return value (serialized via
pr-str)
Session state persists between evaluations — defs and requires carry forward, so earlier code blocks can define functions used by later ones.
5.4.3 3. Kind detection
The filter examines the :janqua/kind field from the wrapper response:
| Kind | Output |
|---|---|
:kind/hiccup |
Hiccup data is converted to HTML via a helper function defined in the Jank session |
:kind/html |
String is inserted as raw HTML |
:kind/md |
String is parsed as Markdown and inserted |
:kind/hidden |
Output is suppressed (code still evaluates) |
:kind/mermaid |
String is rendered as a Mermaid diagram (client-side JS) |
:kind/graphviz |
String is rendered as a Graphviz diagram (client-side JS via viz-js) |
:kind/tex |
String is rendered as a LaTeX formula |
:kind/code |
String is displayed as syntax-highlighted Clojure code |
:kind/vega-lite |
Map is rendered as a Vega-Lite chart |
:kind/plotly |
Map is rendered as a Plotly chart |
:kind/echarts |
Map is rendered as an ECharts chart |
:kind/cytoscape |
Map is rendered as a Cytoscape graph |
:kind/highcharts |
Map is rendered as a Highcharts chart |
| (none) | Value is shown as a syntax-highlighted code block |
For string-valued kinds (kind/html, kind/md, kind/mermaid, kind/graphviz, kind/tex, kind/code), strings can’t hold metadata directly (they’re primitives in Jank). The convention is to wrap them in a vector: ^:kind/html ["<b>bold</b>"] — the filter auto-unwraps the first element.
5.4.4 4. Hiccup rendering
When a value has :kind/hiccup metadata, the filter calls janqua.runtime/hiccup->html — a function bootstrapped in a dedicated janqua.runtime namespace on first use. This converts Clojure-style hiccup data structures into HTML strings:
[:div {:class "box"} [:span "hi"]]
;; becomes:
"<div class=\"box\"><span>hi</span></div>"This runs inside Jank (not in Lua), so it has access to the full Clojure data model.
5.4.5 5. JS chart rendering
For chart kinds (kind/plotly, kind/vega-lite, kind/echarts, kind/cytoscape, kind/highcharts), the filter:
- Converts the Clojure value to JSON by calling
janqua.runtime/to-json— a function bootstrapped in the Jank session alongsidejanqua.runtime/hiccup->html - Emits a
<div>with a unique ID and a<script>tag that loads the chart library from CDN and renders the spec
Each chart kind loads its own library (e.g., Plotly.js, Vega-Lite + Vega Embed, ECharts) from a CDN. Multiple charts on the same page get unique div IDs to avoid collisions. The CDN URLs are pinned to exact versions and tagged with Subresource Integrity (SRI) hashes, so the browser refuses to execute a script if its bytes don’t match — protecting viewers against a CDN compromise or a malicious patch pushed under a loose version tag.
5.4.6 6. Diagram rendering
Mermaid and Graphviz diagrams can’t use Quarto’s native {mermaid} / {dot} blocks — those are processed as executable cells in an earlier pipeline stage, before Lua filters run.
- Mermaid: The filter loads the Mermaid JS library from CDN and calls
mermaid.render()client-side, similar to the JS chart pattern. - Graphviz: The filter loads
viz-js(a WebAssembly build of Graphviz) from CDN and callsviz.renderSVGElement()client-side. No localdotinstall needed.
5.5 Error handling
When something goes wrong inside the filter — a chart’s value can’t be encoded to JSON, hiccup-to-HTML conversion fails, the Kindly wrapper response can’t be parsed — Janqua surfaces the failure as a visible error block in the rendered document, mirroring how user-code exceptions are shown. Each block contains the failure reason and the original Clojure value so the cause is debuggable from the rendered page alone. The same message is also written to stderr so build logs flag the failure even when nobody opens the HTML.
5.6 Code block attributes
See Authoring Jank documents § Code block attributes for the user-facing reference.
5.7 Bootstrap sequence
On the first evaluation in a document, the filter:
- Resolves the nREPL port (auto-starting Jank if needed)
- Sends bootstrap definitions to a dedicated
janqua.runtimenamespace in the Jank session:janqua.runtime/hiccup->html(hiccup-to-HTML conversion) andjanqua.runtime/to-json(Clojure data-to-JSON conversion for chart kinds). The bootstrap usescreate-ns+internrather than(ns ...) (defn ...), so the user’s current namespace is never switched — subsequent code blocks evaluate in a clean session. - Begins processing code blocks in document order
Subsequent renders reuse the same Jank process, so startup cost is only paid once.