wdoc · reference

Callouts, footnotes and chapter headers

14 min read · 2026-08-21 · wcl 0.33.2-alpha

Three blocks in this chapter are apparatus: they mark a page up rather than being its prose. A callout lifts one paragraph out of the flow and gives it a type. A footnotes section collects the notes that the [^id] markers in your prose point at. A chapter_header opens a page with a kicker, a title and a meta line.

They belong in one chapter for a reason beyond page furniture. Each lowers to exactly one node of the semantic content IR: Content::Callout, Content::Footnotes, Content::ChapterHeader. All three output targets render that node from the same declaration1. No backend re-implements a callout. That is why this chapter can tell you what each block becomes in HTML, in Markdown and in print, and why the three answers differ where they do. Writing your own blocks covers the lowering mechanism itself.

Every file and every output below was built with wcl wdoc build, wcl wdoc build --type markdown and wcl wdoc build --type pdf before it was written down. Type them and compare.

One page, three blocks

Save this as apparatus.wcl. It is a complete document: a site, one page, and all three blocks.

apparatus.wclwcl
# apparatus.wcl — a page with all three apparatus blocks.
import <wdoc.wcl>

site manual {
  default_template = :book
  title            = "Manual"
  toc { chapter "Rollout" { page = rollout } }
}

page rollout {
  title = "Rollout"

  chapter_header "Rolling out a release" {
    kicker       = "Chapter 3 · Operations"
    reading_time = "6 min read"
    updated      = "2026-08-07"
    version      = "deploy-tool 2.1"
  }

  p "Drain the node before you restart it[^drain]. The scheduler needs 30 seconds[^grace]."

  callout "Check the queue depth" {
    class = ["warning"]
    body  = "A **backed-up** queue means the drain will not finish. Watch `queue_depth` first."
  }

  footnotes {
    footnote drain { text = "`drain` marks the node unschedulable and waits." }
    footnote grace { text = "The **grace period** is configurable." }
  }
}
console
$ wcl wdoc build apparatus.wcl --out out
wrote 1 page

Here is what out/rollout.html holds, with the template chrome and the stylesheet dropped:

out/rollout.html (excerpt)html
<header class="chapter-header">
  <p class="chapter-kicker">Chapter 3 · Operations</p>
  <h1 class="heading-1" id="rolling-out-a-release">Rolling out a release</h1>
  <p class="chapter-meta">6 min read · 2026-08-07 · deploy-tool 2.1</p>
</header>
<p>Drain the node before you restart it<sup class="footnote-ref" id="fnref-drain"><a href="#fn-drain">1</a></sup>. The scheduler needs 30 seconds<sup class="footnote-ref" id="fnref-grace"><a href="#fn-grace">2</a></sup>.</p>
<div class="callout warning">
  <div class="callout-heading">
    <svg class="wdoc-icon callout-icon"><use href="_wdoc/icons.svg#lucide-triangle-alert"/></svg>
    <p class="callout-title"><span>Check the queue depth</span></p>
  </div>
  <div class="callout-body"><p>A <span class="bold">backed-up</span> queue means the drain will not finish. Watch <span class="code">queue_depth</span> first.</p></div>
</div>
<section class="wdoc-footnotes">
  <div class="wdoc-footnotes-title">Footnotes</div>
  <ol class="wdoc-footnote-list">
    <li class="wdoc-footnote-item" id="fn-drain"><span class="code">drain</span> marks the node unschedulable and waits.<a class="wdoc-footnote-back" href="#fnref-drain"></a></li>
    <li class="wdoc-footnote-item" id="fn-grace">The <span class="bold">grace period</span> is configurable.<a class="wdoc-footnote-back" href="#fnref-grace"></a></li>
  </ol>
</section>

Four things in that output are not in the source, and each is a section of this chapter. The callout grew an icon and a warning class. The [^drain] in the prose became a numbered superscript. The footnote list is anchored at fn-drain, and the superscript at fnref-drain. And the chapter header's three meta fields collapsed into one line, joined by a separator2 that nothing in apparatus.wcl chose.

Callouts

A callout is an admonition: a coloured heading with an icon over a body of prose. It takes its heading as the inline label, and everything else as fields.

wcl
callout "Check the queue depth" {
  kind  = :warning
  body  = "A **backed-up** queue means the drain will not finish."
  icon  = "lucide.siren"     # optional — overrides the per-kind default
  id    = queue_depth        # optional — an explicit HTML id
}

Both the heading and the body run through the inline-pattern engine, so **bold**, ` code , links, :icons: and ` all work in either. See Text and formatting.

The six kinds

There are six built-in kinds, and you select one with the kind field:

Preview

Note

Background the reader should keep in mind.

Info

Neutral information worth surfacing.

Tip

A shortcut or a better way round.

Warning

Something to be careful about.

Error

A failure, or a hard constraint.

Success

Confirm that an action completed.

Note

Background the reader should keep in mind.

Info

Neutral information worth surfacing.

Tip

A shortcut or a better way round.

Warning

Something to be careful about.

Error

A failure, or a hard constraint.

Success

Confirm that an action completed.

Example

callout "Note" {
  kind = :note
  body = "Background the reader should keep in mind."
}
callout "Info" {
  kind = :info
  body = "Neutral information worth surfacing."
}
callout "Tip" {
  kind = :tip
  body = "A shortcut or a better way round."
}
callout "Warning" {
  kind = :warning
  body = "Something to be careful about."
}
callout "Error" {
  kind = :error
  body = "A failure, or a hard constraint."
}
callout "Success" {
  kind = :success
  body = "Confirm that an action completed."
}

kind is typed CalloutKind?, a closed symbol set: :note, :info, :tip, :warning, :error, :success and nothing else. Write :dagner and the build stops with a schema violation naming the field. That matters more than it sounds, and the next section is about why.

The symbol is what travels. Each backend maps it to its own vocabulary — a hue, a glyph, an alert keyword — and none of them matches a class name. The six kinds mean the same thing in every output, and no two of them look alike in any of the three:

kindDefault iconThemed hueUnthemed accentMarkdown alert
:notelucide.pencil--wdoc-blue#5e81ac> [!NOTE]
:infolucide.info--wdoc-cyan#88c0d0> [!IMPORTANT]
:tiplucide.lightbulb--wdoc-purple#b48ead> [!TIP]
:warninglucide.triangle-alert--wdoc-yellow#d08770> [!WARNING]
:errorlucide.circle-x--wdoc-red#bf616a> [!CAUTION]
:successlucide.circle-check--wdoc-green#a3be8c> [!TIP]
unsetno iconnot re-pointed#888> [!NOTE]

One hue per kind, and one glyph per kind. There used to be neither: note and info shared a blue and shared lucide.info, and tip borrowed the cyan that info now owns. tip moved to purple so success could keep green, which is what "it worked" means everywhere.

The icons come from the bundled Lucide pack, so a callout draws one with no iconset declared — in print as well as on the web. Icons covers the packs.

Six kinds through a five-keyword syntax

GitHub has five alert keywords and wdoc has six kinds, so the Markdown target cannot label all six. All five keywords are used and success shares TIP with tip, the closest positive admonition GitHub offers.

The kind is not lost to that. Every kinded callout also emits an HTML comment under the keyword, which GitHub renders as nothing and anything reading the Markdown as data can pick up:

markdown
> [!TIP]
> <!-- wdoc-callout: success -->
> **Migration complete**
> Every row moved.

So a reader sees five distinct labels where they used to see four, and a program sees all six. A callout with no kind gets no marker, because there is nothing to record.

Colour, and where print gets it

Every accent rides one CSS custom property, --callout-accent. lib/callout.wcl gives each kind an unthemed default — the hex column above, and what a site with no theme renders. A site that does declare a theme re-points each kind at its palette's hue ring instead, so the colour follows the theme rather than the table.

Print follows the theme too, and reads its light palette, because a PDF page is a light medium. That is the same rule the diagram card fill already used. A document with no site theme prints the unthemed hex values, so print and unthemed web agree.

The demo above is themed and the hex column is not

Hold the six previews next to the Unthemed accent column and they will not match, and that is the mechanism working. Build the same six callouts in a site with no theme field and you get the hex values exactly. Themes covers the hue ring.

The older way: one class picks the kind

Before kind existed, the kind was read off class, and it still is when kind is unset. Nothing written the old way has to change. class is a list, and it is a style hook that happens to carry the kind. A callout may hold several classes; exactly one kind comes out of them. The lowering tests them in a fixed order and takes the first hit:

So class = ["tip", "warning"] is a warning, whichever order you wrote them in. The HTML keeps both names, the kind first and then whatever you supplied that is not already there, and renders <div class="callout warning tip">. An explicit kind wins over the whole list.

The reason to prefer kind is what happens to a typo. A class the list does not name leaves the callout kind-less: no accent class, no icon, the neutral grey accent, and > [!NOTE] in Markdown. The build finishes. All you get is a class-lint warning about a name nothing styles, which is easy to miss in a long build and says nothing about callouts. kind = :dagner cannot do that — the vocabulary is closed and the build stops.

Write one kind per callout

Two kind names in one class list is not an error and will not warn. It silently resolves to whichever is higher in the list above. Use kind and the question does not arise.

A callout of your own

A kind name the six do not include is still a perfectly good class. Give it an accent and you have styled a new type without writing CSS. accent on a class block emits the --callout-accent custom property, which the callout's heading, left border and icon all read:

One line declares the type. Note where it lands: a class is a document-root block, so it sits beside your pages rather than inside one. This chapter declares deploy immediately above its own page, and the rule reaches every page of the book.

wcl
class "deploy" { accent = "#b48ead" }

The callout then names it, and icon picks the glyph. The demo below shows the source and the live result together. See Demo blocks:

Preview

Deploying

A custom type: the deploy class sets the accent, icon picks the glyph.

Deploying

A custom type: the deploy class sets the accent, icon picks the glyph.

Example

callout "Deploying" {
  class = ["deploy"]
  icon = "lucide.rocket"
  body = "A custom type: the `deploy` class sets the accent, `icon` picks the glyph."
}

That purple is the colour you asked for, on a themed page, and the six built-in kinds cannot promise that. The theme's re-pointing rules name the six kinds one by one. deploy is not among them, so nothing overrides your accent. Themes covers class, accent and the rest of the styling vocabulary.

A custom type is an HTML-only type

deploy is not a CalloutKind, so the node carries no kind at all. In HTML that is fine: your class paints it. In Markdown it becomes > [!NOTE], and in print it takes the neutral grey accent. Neither target has any way to learn your colour. If a callout has to read as a warning everywhere, give it class = ["warning", "deploy"] and let deploy do the extra painting.

The body is one paragraph

body is a utf8 field, not a nested block list. A callout holds prose and nothing else: no code sample, no list, no nested diagram. That is deliberate: the printed callout paints one shaped heading over one shaped body, and a box that could hold a page could not be painted at all.

When the box has to sit beside something richer, put the richer thing next to it rather than inside it. A code block after the callout reads the same way and works on every target. See Code.

Footnotes

A footnote is two halves in two places. The definitions live in a footnotes block, conventionally at the foot of the page. The references are [^id] markers written inline in your prose.

wcl
p "Drain the node before you restart it[^drain]."

footnotes {
  footnote drain { text = "`drain` marks the node unschedulable and waits." }
  footnote grace { text = "The **grace period** is configurable." }
}

A footnote carries exactly two things: its id, written as the inline label and typed as an identifier, and its text, which runs through the inline-pattern engine like any other prose. It is not a block that renders on its own. It has no lowering and no native implementation, exactly like an li under a list. Only the parent footnotes block renders, and it maps each definition into one entry of its content node.

The section title is fixed. The footnotes block always lowers with the title "Footnotes". The IR field behind it is optional, so another block lowering to Content::Footnotes may set its own or omit it, but the stdlib block gives you no field to change it.

The marker is the id

Everything else hangs off one fact. A footnote's marker is its declaration id, not a number. The id is what both ends of the link are anchored on:

AnchorWhereWhat writes it
fn-<id>the <li> in the footnote listthe HTML reading of Content::Footnotes
fnref-<id>the <sup> planted in your prosethe page-wide [^id] rewrite
[^<id>]:the definition line in Markdownthe Markdown reading, a real GFM label
<id>. the prefix on the printed notethe PDF reading

Read the HTML excerpt at the top of this chapter against that table. id="fn-drain" on the list item, href="#fn-drain" on the superscript, id="fnref-drain" on the superscript, and href="#fnref-drain" on the back-link that returns you to where you were reading. Two anchors, four references, one id.

The rewrite is what joins them, and it is a separate pass over the finished page. It scans the rendered HTML for id="fn-…" definitions, then replaces each matching [^id] in the page with the superscript. That order has a consequence worth stating plainly: a footnote you define but never reference still renders, and its points at an anchor that is not on the page.

Numbering follows the definitions

The number a reader sees is not part of the marker. It comes from the <ol> in HTML, and from the rewrite pass in the prose. That pass numbers by the order the definitions appear, not the order the references do.

Swap the two references in the prose of apparatus.wcl and leave the footnotes block alone:

wcl
p "First a reference to grace[^grace], then to drain[^drain]."

footnotes {
  footnote drain { text = "Defined first." }
  footnote grace { text = "Defined second." }
}
html
<p>First a reference to grace<sup class="footnote-ref" id="fnref-grace"><a href="#fn-grace">2</a></sup>,
   then to drain<sup class="footnote-ref" id="fnref-drain"><a href="#fn-drain">1</a></sup>.</p>

The first marker in the text is number 2. Order your footnote blocks the way you want them numbered, and the references will follow. If you want them numbered in reading order, write the definitions in reading order.

What the rewrite will and will not touch

The rewrite touches only ids with a definition. That is what lets a regex character class survive in a code sample: [^abc] is left alone because no footnote is called abc. But understand why it survives, because the rule is narrower than it looks.

The rewrite is a string replacement over the whole page

It runs after the page is rendered, over the finished HTML, code samples included. It does not know what a code block is. Define a footnote called 1 and every literal [^1] on that page becomes a footnote link, inside your code block as much as in your prose. Name your footnotes after what they say (drain, grace, retry-budget), never after something that could appear in a listing.

This chapter takes its own advice. Its two live footnotes are called ir and sep, because drain and grace appear in the code samples above and would have been rewritten inside them.

Footnote links need a template

The rewrite pass runs when a page renders through a template. That covers every book, website and deck. A site with no default_template and no per-page template renders the definitions correctly and leaves every [^id] in the prose as literal text. The same is true of the heading anchor ids, for the same reason. See Templates and layouts.

Chapter headers

A chapter_header is a page's opening block: a kicker above the title, the title itself, and a meta line under it. It takes the title as its inline label and four optional strings:

wcl
chapter_header "Rolling out a release" {
  kicker       = "Chapter 3 · Operations"   # the eyebrow above the title
  reading_time = "6 min read"
  updated      = "2026-08-07"
  version      = "deploy-tool 2.1"
  id           = rollout_header             # optional explicit HTML id
}

Every one of the four is optional, and each is a plain string that wdoc prints rather than computes. reading_time is not measured for you, and updated is not read from git. The block records what you tell it.

How much inline formatting survives depends on which part and which target. The kicker runs through the inline-pattern engine everywhere. The title does in HTML and Markdown, but the PDF backend takes it as plain text, so **bold** in a title prints with its asterisks. The meta line runs through the engine on no target at all: HTML escapes it, print draws it as plain text, and Markdown passes it out untouched. Keep markup out of all three and the question never arises.

The meta line

reading_time, updated and version are not three separate fields on the page. They are three parts of one line, and wdoc assembles the line from whichever of them you set, in that order, joined by the separator " · ". Set one and you get one part with no separator at all. Set none and there is no meta line.

Fields setThe line
all three6 min read · 2026-08-07 · deploy-tool 2.1
updated + version2026-08-07 · deploy-tool 2.1
version onlydeploy-tool 2.1
noneno line is emitted

That separator is one shared fact, not three coincidences: HTML, Markdown and PDF all read the assembled line from the same function. A meta line reads identically in the browser, in the Markdown output and on the printed page. Only its wrapper differs.

A chapter header is the page h1

The header emits <h1 class="heading-1"> for its title, the same shape an h1 block emits, and for the same reason. The page-wide heading pass matches that shape, so a chapter header's title gets an anchor id derived from its text exactly as any other heading does. "Rolling out a release" became id="rolling-out-a-release" in the excerpt at the top of this chapter, and another page links to it by writing that slug after a #: [the rollout header](rollout#rolling-out-a-release).

The practical rule follows: use chapter_header instead of h1, not as well as it. Writing both gives the page two level-one headings, two anchor ids and two entries in every outline that reads them.

The CSS knows about a subtitle but the block does not

The stylesheet carries a .chapter-subtitle rule and the content node carries a subtitle field, but chapter_header declares no such field. Writing one fails the build with field 'subtitle' is not declared by schema 'ChapterHeader'. The field is reachable only from a block of your own that lowers to Content::ChapterHeader. See Writing your own blocks.

Three blocks across the targets

This is the comparison worth keeping. Build apparatus.wcl a second and a third time:

console
$ wcl wdoc build apparatus.wcl --out md --type markdown
wrote 1 page
$ wcl wdoc build apparatus.wcl --out pdf --type pdf
wrote 1 pdf

md/rollout.md, in full:

md/rollout.mdmarkdown
_Chapter 3 · Operations_

# Rolling out a release

_6 min read · 2026-08-07 · deploy-tool 2.1_

Drain the node before you restart it\[^drain\]. The scheduler needs 30 seconds\[^grace\].

> [!WARNING]
> **Check the queue depth**
>
> A **backed-up** queue means the drain will not finish. Watch `queue_depth` first.

## Footnotes

[^drain]: `drain` marks the node unschedulable and waits.

[^grace]: The **grace period** is configurable.

And the same page as text out of the PDF:

console
$ pdftotext pdf/manual.pdf -
...
Chapter 3 · Operations

Rolling out a release
6 min read · 2026-08-07 · deploy-tool 2.1
Drain the node before you restart it[^drain]. The scheduler needs 30 seconds[^grace].
Check the queue depth
A backed-up queue means the drain will not finish. Watch queue_depth first.

Footnotes
drain. drain marks the node unschedulable and waits.
grace. The grace period is configurable.

Read the three outputs against each other and the whole design shows through:

BlockHTMLMarkdownPDF
callout<div class="callout warning"> + icon + accent> [!WARNING] alert blockquotea box with the kind's accent, bold heading
footnotes<ol> numbered, fn-/fnref- anchors, back-links[^id]: GFM definition linesa level-2 heading, then id. text paragraphs
[^id] in prosea numbered superscript linkescaped, left as textleft as text
chapter_header<header> + kicker <p> + <h1> + meta <p>_kicker_, # title, _meta_a paragraph, a level-1 heading, a paragraph

Two rows of that table deserve a sentence each. The reference row is the one asymmetry in the whole chapter: definitions cross to Markdown as real GFM labels, and references do not cross at all. The inline engine that would have to rewrite them runs before the page's definitions are known, and it cannot tell [^drain] in prose from [^0-9] in a regex without them. That is the same fact that makes the HTML rewrite a separate, definition-driven pass. And the footnote row shows what the marker being an id really buys: the reader sees 1 in the browser, [^drain] in Markdown and drain. in print, from one declaration, because the number was never the thing being carried.

Output targets covers what each of the three targets does with a page.

Where to go next

Footnotes
  1. The union is closed and every backend matches it exhaustively, so a variant added to it is a compile error in three renderers rather than silence in three outputs.
  2. A middle dot with a space either side. It is one shared function, not three copies. See The meta line.