flowchart LR qmd[.qmd file] --> pandoc[Pandoc] pandoc --> lua[Lua filter] lua --> nrepl[Jank nREPL server] nrepl --> lua lua --> out[HTML / PDF]
3 How It Works
This chapter explains the internals of Janqua — how your {.clojure .jank} code blocks turn into rendered output.
3.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
3.2 Port discovery
The filter needs to find a running Jank nREPL server. It tries several sources in order, validating that each candidate port is actually reachable before using it:
- 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.
3.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 project root:
| 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.
3.4 Code evaluation
Each {.clojure .jank} code block goes through these steps:
3.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.
3.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.
3.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 or :kind/markdown |
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 SVG via the dot command |
: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.
3.4.4 4. Hiccup rendering
When a value has :kind/hiccup metadata, the filter calls janqua-hiccup->html — a function bootstrapped in the Jank session 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.
3.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-to-json— a function bootstrapped in the Jank session alongsidejanqua-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.
3.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 pipes the DOT source through the
dot -Tsvgcommand and embeds the resulting SVG directly. This requires Graphviz to be installed on the build machine.
3.5 Code block attributes
The filter respects several attributes on {.clojure .jank} blocks:
| Attribute | Effect |
|---|---|
output=html |
Force HTML output (override Kindly) |
output=markdown |
Force Markdown output |
output=hidden |
Suppress both code and output |
echo=false |
Hide the source code, show only results |
eval=false |
Show code without evaluating |
Kindly metadata on the result takes precedence over output= when both are present.
3.6 Bootstrap sequence
On the first evaluation in a document, the filter:
- Resolves the nREPL port (auto-starting Jank if needed)
- Sends bootstrap definitions to the Jank session:
janqua-hiccup->html(hiccup-to-HTML conversion) andjanqua-to-json(Clojure data-to-JSON conversion for chart kinds) - Begins processing code blocks in document order
Subsequent renders reuse the same Jank process, so startup cost is only paid once.