2 The rendering pipeline
Text to block list: the parse stages, the block contract every emitter honours, citations and cross-references, and output assembly.
Rendering is where post.tmd stops being text and becomes the block list
everything else keys off: its heading, its paragraph, and its code cell, each a
separate block with its own identity. The entry point is
render_document_with_includes(src, base_dir) in render/mod.rs; it runs a fixed
sequence of stages, each a small, testable function.
flowchart TB
src[".tmd source"] --> inc["includes::resolve<br/>{{< include >}} + source map"]
inc --> sc["expand_shortcodes<br/>extension {{< name >}} templates"]
sc --> spans["scan_div_spans<br/>record ::: fenced divs"]
spans --> pre["preprocess<br/>blank ::: markers (line-preserving)"]
pre --> ast["comrak parse_document<br/>(+ sourcepos)"]
ast --> walk["walk top-level nodes<br/>→ FlatBlock (emit HTML, hash id)"]
walk --> group["group_divs<br/>rewrap into callouts/columns"]
group --> cite["table captions + cite::process<br/>citations, @refs"]
cite --> blocks["RenderedDoc { blocks, title, … }"]
2.1 Stage by stage
- Include resolution (
includes.rs).{{< include path >}}shortcodes are expanded inline, and aVec<LineOrigin>source map records, for every line of the expanded text, which file + line it came from. Cycles are detected and left in place. This is what makes click-to-source work across files. - Shortcode expansion (
render/extension/). The built-in{{< input >}}tag expands to its HTML template, the only shortcode that expands here since{{< include >}}was resolved a pass earlier. It is line-preserving (each tag opens and closes on one line, the template’s newlines collapsed) so the source map from stage 1 stays valid. - Fenced-div scan + preprocess (
render/divs.rs). The:::fenced divs aren’t CommonMark, so their spans are recorded first, then the marker lines are blanked without changing the line count (again preserving sourcepos). The inner content then parses as ordinary blocks. - Parse (
comrak, withsourceposon). One AST for the whole document, with every node carrying itsstartLine:col-endLine:col. The parse and the walk that follows run on a worker thread with a 256 MB stack (render_internalspawns a scoped thread,stack_size(256 * 1024 * 1024)). The reason is concrete: deeply nested Markdown (blockquotes, lists) drives deep recursion in both comrak’s parser and Taliesin’s block emission, and on the default ~8 MB thread stack that recursion overflows at around 3000 levels. A stack overflow is not a catchable panic, it aborts the whole process: one pathological document would crash abuildor take down the live preview server outright. The big stack absorbs any realistic nesting; an ordinary panic inside the worker is propagated to the caller unchanged. (If spawning the big-stack worker fails, for instance under a strictulimit -v, render falls back to the current default-stack thread rather than failing the render.) - Walk → FlatBlock (
render/mod.rs+render/emit.rs). Each top-level node becomes one block: the emitter renders it to HTML (code highlighted byhighlight.rs, math bymath.rs), and the block gets a content-hash id and adata-sourceposmapped back through the source map. A```{lang}fence is recognised as an executable cell and recorded for the server to run later. The front-matter node is where the document’s metadata is resolved. - Regroup divs (
render/divs.rs). The flat blocks are wrapped back into callout / layout-grid / generic container blocks using the spans from stage 3. Inner blocks keep their own ids and sourcepos, so click-to-source works inside a callout. - Captions + citations (
apply_table_captions, thencite/). A Pandoc table caption (: caption {#tbl-x}on the paragraph directly after a table) is not a sibling block in the output:apply_table_captionsnumbers it, folds it into the table as a<caption>first child, registerstbl-xfor@tbl-references, and then removes the caption paragraph block so the caption lives as metadata on the table block. After that,[@key]citations and@fig-/@sec-/… cross-references are resolved against the bibliography and the cross-reference registry.
The result is a RenderedDoc: an ordered Vec<Block> plus the title, subtitle, TOC
flag, includes, the cross-reference number map, and any non-fatal warnings. Every
block in it carries the three data attributes the rest of the system keys off,
data-block-id (hashed from the block’s raw Markdown source by make_id),
data-sourcepos, and data-source-file on included blocks. The block
model is the chapter on them; crates/core/tests/corpus.rs enforces
the contract over every corpus document.
2.2 Two line coordinate systems
After stage 1 there are two kinds of line number, and confusing them is the bug that
keeps happening. A post-include buffer line (what group_divs matches ::: spans
in, what comrak’s sourcepos reports) is a BufLine: a newtype in render/model.rs
with no Display and no conversion, so it cannot be formatted into a data-sourcepos
or passed to Warning::at. The way out is map_origin / map_span, which return the
author’s own file and line; past that point there is only one system left, so the source
side stays a bare number.
The compiler holds that line, rather than a convention, because both halves of the
mistake are invisible: an unmapped line paired with a data-source-file lands N lines
off inside a real, openable file, and mapping the two ends of a range separately emits
spans like 39:1-6:25 on a paragraph comrak merged across an include boundary, which
the client silently skips. map_span maps both ends together, which answers both.
2.3 Citations & cross-references
Citations and cross-references (cite/) are the last pipeline stage, and they
run as a post-process over the already-rendered block HTML: they transform only
plain-text runs, never the inside of a tag, code, or math span, so block sourcepos
is left untouched. [@key] citations become numbered links to a References block
appended to the document (the one structural change), formatted from the parsed
BibTeX. @fig- / @sec- / @tbl- / @lst- / @eq- references become links to their
anchor, carrying the resolved number when it is known. It is deliberately pragmatic, not
a full CSL engine. Cross-page references (a @fig- that lives in another
chapter) are resolved a layer up, in the site subsystem (crates/core/src/site/xref.rs).
Markdown footnotes ([^name]) are the one construct whose content is written in one
place and rendered in another, and the mechanism is worth being precise about:
- A footnote reference (
[^name]) renders in place, as a superscript link inside its surrounding block (emit.rs). - A footnote definition (
[^name]: …) does not.11This note is the demonstration. Its text was written under the bullet list in the source, and it renders here, in the margin beside the bullet that references it.Back It is spliced in as a margin sidenote immediately after its own reference, the only position CSS can float into the margin from: no selector can relocate an end-of-document element to sit beside an arbitrary earlier one, so the move happens at render time. There is no gathered endnote section.
comrak moves every definition to the end of the document, so a walk that met a reference
first would not yet have seen its definition. render_internal_impl therefore runs a
pre-pass over the root’s children collecting definitions into a map before the main
walk begins, the same shape as the heading-level pre-scan just above it.
More subtly: a block id is hashed from the block’s source lines, not from its emitted HTML. A note’s source lives somewhere else entirely while its text renders inside the referencing block, so the definitions a block displays are folded into that block’s hash input. Without that fold, editing a note would leave every block id identical, the diff would emit no operation, and the live preview would silently keep showing the old note: a failure with no error and no visible symptom except staleness. Only the notes a block actually displays are folded in, so editing one note does not churn the ids of blocks that reference a different one.
Because the note renders as phrasing content inside another block, a definition carrying
block content (a list, a quote, a code block) is flattened to its text with a warning: an
<ul> inside a <span> inside a <p> makes the HTML parser close the paragraph early,
which would leave the block with two root elements and break the one-root-element
invariant the block swap depends on.
2.4 Incremental updates
On a save the server re-runs this whole pipeline and hands the fresh block list to
diff_blocks(old, new), which turns it into the handful of ops that cross the
websocket. The block model is that algorithm.
2.5 Output assembly
A RenderedDoc becomes a full page through one assembler, render/page.rs
(assemble_html_page). The blocks are joined into the body, and that body is dropped
into a single fixed HTML shell (the PAGE_TEMPLATE format! string) that supplies the
page chrome the reader never authored:
- A pre-paint theme bootstrap in
<head>(theme_head): a tiny inline<script>that reads the reader’s device preference (prefers-color-scheme) and setsdata-themeon<html>before the first paint, so a dark-mode reader never sees a white flash, then keeps following the OS live. Both palettes always ship and the device alone decides: there is no author theme key and no reader toggle in a built page. - The bundled offline assets:
base.css, the dark layer, the optional site CSS, and (only when the document actually uses math) the KaTeX stylesheet, allinclude_str!d into the binary so a built page is self-contained with no CDN. - A skip-to-content link when the body carries the focusable
<main id="tali-main">, plus the chrome’s own head markup (per-page OpenGraph/SEO meta and the feed links). No author-configured markup reaches the<head>. - For a page with
{js}cells, the vendored d3 + Observable Plot libs and the{js}enhancer (gated on the rendered body, so a page without{js}carries none of it).
A site/book page adds chrome from site/, but the same assembler builds the page
shell for the static build and for the live-preview server (preview just injects the
websocket client into that shell), so they can’t drift apart. The same block list backs
every output (see Architecture).
2.6 Interactive cells ({js})
{js} cells are client-side, not kernel-executed, so they sit outside the
execution path. A small native enhancer (tali-js.js)
runs each cell’s source against a small per-cell scope (tali.get/set/value,
onInput, container, invalidation); it is a small scheduler, not a full
reactive VM. Cell kinds come from //| options: //| viewof: name returns a DOM
input registered under name, //| name: x publishes the return value into a
shared scope, and //| input: a, b re-runs the cell when input a or b fires.
From the cells’ defines/inputs the enhancer builds a dependency graph
(buildGraph): a name → consumers map plus a global topological order via Kahn’s
algorithm. When an input fires, scheduleFrom re-runs exactly the cell’s
transitive-downstream closure (following each hit cell’s own defines, so a
n → squared → … chain re-runs end to end), once each, in that topological order,
rather than cascading listener fires. The initial run is a single document-order
(producer-before-consumer) awaited pass; cells left over after Kahn’s are in a cycle
and show a diagnostic instead of running. A Python cell’s define(...) bridges
values across via a <script type="tali-define"> blob the enhancer ingests,
re-running dependents when it lands after first paint.
That graph is running in this page. The slider is a {{< input >}}, the cell under it
declares //| input: tau, and dragging one re-runs exactly the other, redrawing our
running example’s cooling curve in the browser rather than in the kernel: