Writing your own blocks
Forty chapters of this book describe blocks somebody else wrote. This one describes how to write your own. A wdoc block is not a plugin and not a macro. It is an ordinary WCL @block type that extends one of three interfaces. It carries a function that turns an instance into output, and the renderer calls that function. Nothing else about the block is special.
Three mechanisms add a kind, and this chapter covers all three. A lowering is a WCL function on your type. A component is declarative markup with slots and no function at all. @native is a Rust implementation, and only wdoc's own blocks may claim it. Every example below was built before it was written down. Type them and compare.
§ 1Two ways a block renders
wdoc renders a block exactly one of two ways, and the block's type says which:
- lower — a function the renderer calls with a record of the block's fields. It returns a list of nodes the renderer knows how to draw. This is the path open to you.
- @native — the renderer implements the kind in Rust and never calls a lowering. This path is closed to user blocks; see Native blocks.
Declaring both, or neither, fails the build. That check is the whole point of the split. Before it existed, 57 stdlib types declared a lower returning [] while Rust intercepted the kind. Each of those was dead code. It satisfied an interface and nothing else, and the editor's schema introspection then read it as truth.
Because a native block must not fake a lowering, lower is declared optional on all three interfaces. The type system therefore does not make it exactly-one-of; a build check does:
$ wcl wdoc build api.wcl --out _site
wdoc::native
× type 'Endpoint' declares neither a `lower` nor `@native` — a block is
│ rendered by a WCL lowering or by wdoc's Rust dispatch, and its type must
│ say which
╭─[api.wcl:10:1]
9 │ @block("endpoint")
10 │ ╭─▶ type Endpoint extends ContentBlock {
╰────
§ 2A block with a lowering, end to end
Here is a complete document. It declares a page block, uses it twice, and needs no Rust, no build step and no configuration. Save it as api.wcl:
# api.wcl — a custom page block, declared and used in one file.
import <wdoc.wcl>
site manual {
title = "Manual"
}
# One REST endpoint: a heading with the verb and path, then the summary.
@block("endpoint")
type Endpoint extends ContentBlock {
@inline(0) verb: utf8
@inline(1) path: utf8
summary: utf8
id: identifier?
class: list<utf8>?
lower = fn(e: Endpoint) -> list<Content> [
Content::Heading { level: 3, text: $"` `", id: e.id },
Content::Paragraph { text: e.summary, class: e.class },
]
}
page api {
title = "API"
h1 "API"
endpoint "GET" "/users" {
summary = "List every user. Paginated by `?page=`."
}
endpoint "POST" "/users" {
summary = "Create a user."
}
}
Build it. The <body> of the emitted page:
$ wcl wdoc build api.wcl --out _site
wrote 1 page
$ cat _site/api.html
...
<body class="wdoc-body">
<h1 class="heading-1">API</h1>
<h3 class="heading-3"><span class="code">GET /users</span></h3><p>List every user. Paginated by <span class="code">?page=</span>.</p>
<h3 class="heading-3"><span class="code">POST /users</span></h3><p>Create a user.</p>
</body>
The same document, rendered to Markdown by the same lowering:
$ wcl wdoc markdown api.wcl --out _md
wrote 1 page
$ cat _md/api.md
# API
### `GET /users`
List every user. Paginated by `?page=`.
### `POST /users`
Create a user.
Read the declaration one line at a time. @block("endpoint") registers the kind, exactly as it would for a data block — see Schemas. extends ContentBlock says the block produces page content, which is what makes it legal inside a page. @inline(0) and @inline(1) bind the two labels, so endpoint "GET" "/users" reads like the thing it describes. summary is an ordinary field.
id and class deserve a note. ContentBlock declares only two members — id, the anchor target, and the optional lower slot. class is not inherited. Declare it yourself, as the examples here do and as every stdlib content block does. Leave it out and an instance that sets a class is refused: field 'class' is not declared by schema 'Endpoint'.
lower is the interesting one. It is a field with a default value — an ordinary function literal, checked and evaluated like any other expression in the document. The renderer looks it up and calls it. Nothing registers it; nothing dispatches on a naming convention.
Note what the lowering returns: two Content nodes, not markup. Content::Heading carries level: 3 as a number. The class="heading-3" in the HTML above is derived from that number by the HTML backend, and the Markdown backend derives ### from the same number. Neither reads the other's output.
Give your kinds a prefix
A page block whose kind matches a template slot name fills that slot instead of rendering as content. The built-in website layout declares the slots content, banner, hero, sidebar and footer. So a block kind named hero never reaches its lowering — and on a bare page, with no layout at all, it renders nothing and reports nothing. The stdlib's own landing components carry an lp_ prefix for exactly this reason. Prefix your kinds. Templates and layouts covers slots.
§ 2.1What the lowering is handed
The argument is a record built from the block's declared fields, one entry per field the schema declares. It is never the raw AST, and never only the fields the author wrote. The rules below are what let a lowering read e.class without guarding:
- Every declared field is present. A field the instance omits arrives as none, or as the schema's declared default if it has one. A lowering never has to test for a missing key.
- Labels fill their @inline(n) field. An explicit verb = "GET" fills the same field when the instance gives no label. So endpoint { verb = "GET" … } lowers identically to endpoint "GET" ….
- Child blocks arrive as records, not as blocks. The schema projection completes each @children(...) record, so it carries every declared field of its own type — including the optionals, as none.
- A field whose expression fails becomes none. The block still lowers. The build records the error, prints the diagnostic and exits non-zero.
The declared return type is a list of fundamentals. The language does not check a function's return type at run time (Functions). The renderer catches a lowering that returns something else:
× error: the `lower` lowering for block kind 'badge' returned utf8 —
│ expected a list of fundamentals
Two returns are not errors. [] renders nothing, and so does none — that is how a lowering opts out conditionally.
§ 2.2Overriding the lowering on one instance
lower is a field, so an instance may set it. The renderer prefers the instance's own lower over the type's default:
badge "default lowering"
badge "this one is different" {
lower = fn(b: Badge) -> list<Content> [
Content::Callout {
heading: b.text,
body: [Content::Paragraph { text: "per-instance" }],
}
]
}
Reach for this rarely. It is the escape hatch for the one instance that does not fit, not a substitute for a second kind.
§ 3The three lowering interfaces
Which interface you extend says what your block produces, and therefore what its lowering may return. It does not say where the block may be written.
| Interface | Lowering returns | Rendered as | Declared in |
|---|---|---|---|
| ContentBlock | list<Content> | Page content — prose, media, apparatus | core.wcl |
| SvgBlock | list<Svg> | Shapes inside a diagram | core.wcl |
| TermPrimitive | list<TermFundamental> | Cells of a terminal grid | terminal.wcl |
Placement is a separate question, and a separate mechanism. A page accepts @children(ContentBlock); a diagram accepts @children(SvgBlock); a terminal accepts @children(TermPrimitive). The accepts-type on the slot is what admits your block — there is no parallel hierarchy of placement interfaces to also extend. Write the endpoint block from the last section inside a diagram and the schema refuses it before any lowering runs, because the slot says so:
× block kind 'endpoint' is not allowed inside 'diagram'
╭─[misplace.wcl:12:5]
11 │ width = 100 height = 60
12 │ endpoint "GET"
· ───────┬──────
· ╰── schema violation
╰────
A TermPrimitive also carries row and col (1-based grid coordinates), because a terminal cell has to sit somewhere. See Terminals and TUI.
§ 3.1A diagram shape
A custom shape is the same shape of declaration with a different interface and a different return union. This one draws a rounded box with a centred label:
import <wdoc.wcl>
site manual { title = "Manual" }
@block("chip")
type Chip extends SvgBlock {
@inline(0) text: utf8
x: f64?
y: f64?
width: f64?
height: f64?
id: identifier?
class: list<utf8>?
lower = fn(c: Chip) -> list<Svg> [
Svg::Rect { x: c.x, y: c.y, width: c.width, height: c.height, rx: 8, class: c.class },
Svg::Label { content: c.text, x: c.x + c.width / 2, y: c.y + c.height / 2 },
]
}
page shapes {
h1 "Shapes"
diagram {
width = 300 height = 90
chip "parser" { id = parser x = 20 y = 20 width = 110 height = 44 }
chip "evaluator" { id = evaluator x = 170 y = 20 width = 110 height = 44 }
parser -> evaluator
}
}
A custom shape is a first-class member of the diagram graph. The parser -> evaluator statement routes an edge between the two chips exactly as it would between two rects:
$ wcl wdoc build chip.wcl --out _site
wrote 1 page
$ cat _site/shapes.html
...
<rect x="20" y="20" width="110" height="44" rx="8" />
...
<rect x="170" y="20" width="110" height="44" rx="8" />
...
<polyline points="130,42 170,42" fill="none" stroke="currentColor" marker-end="url(#wdoc-arrow)" data-kind="default" />
...
The Svg union is small and geometric: Rect, Circle, Line, Label, Polygon, Polyline, and Link (a clickable wrapper around child shapes). The diagram canvas covers layout, anchoring and the connect_points a shape may declare so edges attach to its sides.
§ 4The semantic content IR
Content is the vocabulary a ContentBlock lowering returns. It deserves a section of its own, because its shape is a deliberate decision that constrains what you can write.
It is the target-neutral document vocabulary: one variant per document concept, declared once in lib/content.wcl, consumed by every output target. Three rules hold it together.
- It is closed. There is no Raw variant and no generic Container { role, children }. A concept that is not in the union is not page content.
- One variant per concept. A shared container with a role: symbol would be a stringly-typed key. Each backend would interpret or ignore it, which is the divergence this layer exists to end.
- Semantics live in fields, never in strings. Heading.level is a number. Callout.kind is a symbol from a declared vocabulary. Neither is a class name a backend matches by hand.
class survives as a style hint (the class system is read by the non-HTML targets too) and id as an anchor target. Neither may carry meaning a backend has to parse back out.
The eighteen variants, in the four groups the declaration uses:
| Group | Variant | Carries |
|---|---|---|
| Prose | Heading | level: u8, text |
| Prose | Paragraph | text (inline patterns apply) |
| Prose | List | items, each with its own nested blocks |
| Prose | Table | rows, optional header and caption |
| Prose | Code | source, optional language and filename |
| Prose | Callout | kind, heading, body: list<Content> |
| Prose | Columns | columns, each its own content list |
| Media | Image | source, alt, caption, size |
| Media | Video | source, poster, title, size |
| Media | File | path, label |
| Media | Math | latex, display |
| Media | Drawing | shapes: list<Svg> — the page-level SVG bridge |
| Media | Terminal | lines — the resolved screen |
| Apparatus | Toc | resolved entries, not a marker to expand |
| Apparatus | Footnotes | resolved notes |
| Apparatus | ChapterHeader | title, kicker, and the meta line |
| Presentation | Fragment | body — a step-revealed group on a slide |
| Presentation | SpeakerNotes | body — presenter-only commentary |
Two consequences follow, and both are load-bearing.
The wdoc build script generates the Rust enum from the WCL declaration. It parses lib/content.wcl and walks every type reachable from union Content. From that walk it emits the enum, its supporting records and symbol sets, and the Value conversions. There is no second copy to drift. Reachability is also the closedness check. Some field types cannot cross a backend boundary — a function, a reference, a tensor, a type the stdlib does not declare. One of those fails the build here, rather than becoming a hole the renderers ignore.
Every backend matches the union exhaustively. Three walkers read it: HTML, PDF and Markdown. Not one of them has a catch-all arm. Add a variant to the union and you get a compile error in three places, rather than silence in three outputs. That is the mechanism, not a convention someone remembers to follow.
What the closed union costs you
You give up bespoke page markup. You keep bespoke drawings: a Content::Drawing carries a list<Svg>, so a block that wants a picture nobody has drawn before still has the whole shape vocabulary. The escape hatch that is missing is the one that would let a backend receive something it cannot understand.
§ 4.1Union tag, not variant name
A lowered value is classified by the union it belongs to, never by the name of its variant. This matters because the names collide on purpose: Paragraph, Table and Math are declared by both Content and the HTML vocabulary described next. A name-based test would read one as the other, and would do it silently.
So Content::Paragraph { text: … } and Html::Paragraph { spans: [ … ] } are different nodes with different fields and different reach. Write the union prefix and the compiler of your document — the schema checker — keeps them apart for you.
§ 5The HTML vocabulary, and what it costs
A lowering may also return members of Html, the HTML element vocabulary: Element, Paragraph, Table, Raw, Style, Head, Icon, Inline, Highlighted, Math, and the Blocks placement handle that templates use. The el constructor family is shorthand for the common shapes — el(tag, cls, kids), ela(…, attrs, …), eli(…, id, …), and the leaves raw, inl, icon and para. Documents, pages and sites covers the family in full.
This is how the stdlib's own landing-page components are built, and it is the right tool when you are laying out a marketing page for HTML. Understand what you are trading:
@block("promo")
type Promo extends ContentBlock {
@inline(0) heading: utf8
lede: utf8
id: identifier?
lower = fn(b: Promo) -> list<Html> [
eli("section", b.id, ["promo"], [
el("h1", [], [inl(b.heading)]),
el("p", ["lede"], [inl(b.lede)]),
])
]
}
In HTML that is exactly the tree you wrote:
<p>Before.</p>
<section class="promo"><h1>Ship it</h1><p class="lede">A <span class="bold">fast</span> document generator.</p></section>
<p>After.</p>
In Markdown the section wrapper and its class are gone. The Markdown walker reverse-engineers the structure from the tag names:
Before.
# Ship it
A **fast** document generator.
After.
The degradation is sensible here, and it is not guaranteed to be. A Content::Heading { level: 1 } is a heading everywhere by declaration. An <h1> is a heading everywhere because somebody taught three walkers to recognise a tag name. Prefer Content for anything that has to leave the browser. Reach for Html when the output genuinely is HTML chrome — a backdrop, a nav, a card frame — and expect it to drop away elsewhere.
§ 6Lowering recurses
A lowering does not have to bottom out in one step. The renderer inspects every value it gets back. A value that is neither a Content node nor a member of the target's fundamental vocabulary counts as another block kind's variant. Its record becomes the argument, the renderer resolves and calls that kind's lower, and it walks the results again.
The rule for the mapping is mechanical. A variant named Badge names the kind badge; NodeRow names node_row. Capitals become word boundaries.
@block("badge")
type Badge extends ContentBlock {
@inline(0) text: utf8
id: identifier?
lower = fn(b: Badge) -> list<Content> [
Content::Paragraph { text: $"****", id: b.id }
]
}
# A union whose variant name is the other kind's name, capitalised.
union Parts { Badge { text: utf8 id: identifier? } }
@block("stamp")
type Stamp extends ContentBlock {
@inline(0) text: utf8
id: identifier?
lower = fn(s: Stamp) -> list<Content> [
Parts::Badge { text: s.text, id: s.id }
]
}
page badges {
badge "written directly"
stamp "reached through a second lowering"
}
Both lines reach the same output, and they reach it on every target — the recursion runs in each backend's walker, not only in the HTML one:
$ cat _site/badges.html
...
<p><span class="bold">written directly</span></p>
<p><span class="bold">reached through a second lowering</span></p>
$ cat _md/badges.md
**written directly**
**reached through a second lowering**
The recursion is depth-limited to 32 levels. A lowering that never terminates does not hang the build; it stops and leaves a marker where the content would have been:
<body class="wdoc-body">
<!-- wdoc: lowering depth limit reached -->
</body>
In practice you will build a parent's children yourself more often than you emit another kind's variant. Read the materialised @children records and construct their nodes inside the parent's lowering. Both routes work. Building the tree in one place is easier to follow.
§ 7Native blocks
@native is the other half of lower. It says: wdoc's Rust dispatch renders this kind, and no lowering will be called.
@block("terminal") @native
type Terminal extends ContentBlock { … }
@block("file") @native(backends = [:html, :markdown])
type FileObj extends ContentBlock { … }
The reason a block is native is always the same: its output is not expressible in WCL. Calendar arithmetic, an ANSI grid, an image crop measured from the file on disk, valid nested list markup, the layout of a measured widget. lib/content.wcl classifies all 35 stdlib ContentBlock types, with a one-line reason for each. Sixteen lower to a fixed payload; nineteen need an authored subtree or renderer-only state.
You cannot make your own block native. The kind has to be one wdoc's registry knows about:
× type 'Endpoint' declares `@native`, but wdoc implements no dispatch for
│ "endpoint" — only wdoc's own blocks can be native; a user block is
│ rendered by its `lower`
backends names the output targets whose Rust dispatch handles the kind: :html, :pdf, :markdown. Omitting it means every target, which is the common case — 51 of the 53 registered kinds cover all three. The declaration is cross-checked against the registry in both directions, and both directions are errors:
- A target claimed but not implemented would render nothing at all.
- A target implemented but not claimed would render something nobody declared. That is how the last stub lower got in.
So adding a Rust arm for a kind means adding its registry row and its @native declaration, in the same change. Neither half alone builds.
§ 7.1Uncovered targets
Only two stdlib kinds cover a subset today, and each is a fact about the target rather than an omission. markdown_source needs the HTML build's machinery to tap the Markdown emitter, so it is :html only. file ships a copy into an output folder, and a PDF is one self-contained document with no folder beside it — so file covers everything but :pdf.
Using a native block on a target it does not cover is a build error, not a silent drop:
$ wcl wdoc markdown preview.wcl --out _md
wcl::eval::user_error
× error: `markdown_source` has no :markdown implementation (it is native
│ on :html); remove the block or waive it here with `@except(backends =
│ [:markdown])`
╭─[preview.wcl:11:3]
10 │ p "Body."
11 │ markdown_source { }
· ─────────┬─────────
· ╰── error raised here
12 │ }
╰────
The waiver is the visibility system's backend axis, applied to the instance:
page overview {
h1 "Overview"
p "Body."
@except(backends = [:markdown, :pdf])
markdown_source { }
}
With that decorator the HTML build renders the block and the Markdown build skips the page's preview and prints nothing about it. The split is the point: capability says can't, author intent says don't want to, and the build refuses until the two agree. Visibility covers @only and @except in full.
§ 8Components: the path with no lowering
A lowering is a function, and sometimes a function is more machinery than the job needs. If your block is a fixed arrangement of blocks that already exist, declare a component instead. A component is declarative markup with named slots and no Rust, no function and no interface.
Here is the endpoint block from the top of this chapter, rewritten with no lowering at all:
# comp.wcl — the same endpoint block, with no lowering function.
import <wdoc.wcl>
site manual { title = "Manual" }
wdoc_component endpoint {
slot verb: utf8
slot path: utf8
slot summary: utf8
slot tone: utf8 = "muted" # a default makes the slot optional
wdoc_body {
h3 $"` `"
p $"" { class = [tone] }
wdoc_content { }
}
}
page api {
h1 "API"
endpoint { verb = "GET" path = "/users" summary = "List every user." }
endpoint { verb = "POST" path = "/users" summary = "Create a user." tone = "lead"
p "Rate limited to 10 requests a minute."
}
}
$ wcl wdoc build comp.wcl --out _site
wrote 1 page
$ cat _site/api.html
...
<h1 class="heading-1">API</h1>
<h3 class="heading-3"><span class="code">GET /users</span></h3><p class="muted">List every user.</p>
<h3 class="heading-3"><span class="code">POST /users</span></h3><p class="lead">Create a user.</p><p>Rate limited to 10 requests a minute.</p>
Four things to notice. The component is instantiated by its own name, as a bare block. A slot with a = value default is optional; one without is required, and ? after the type makes it optional with no default. wdoc_content { } marks where the instance's own nested blocks render — which is why the p written inside the second instance lands after the summary. And a slot reference inside the body is an ordinary expression, so $"${verb} ${path}" interpolates it.
A slot may also be a hole other blocks fill, rather than a value. Type it content and write its name where the body should land; the instance fills it with a block of that name:
wdoc_component panel {
slot heading: utf8
slot body: content
wdoc_body {
h3 $""
body
}
}
page limits {
panel { heading = "Limits"
body {
p "One request a second, burst of ten."
}
}
}
content<T> narrows what the hole accepts to one child kind or interface. Where wdoc_content is the single anonymous hole for whatever the instance nests, a named content slot lets one component have several.
The interesting part is what validates all of this. WdocComponent carries @declares_kind(name = 0, params = "slots", body = "body") — the language's way of saying instances of this type declare block kinds of their own. Kind lookup derives a schema for endpoint from the instance's own slots, and then checks an instance like any other block. Omit summary:
× block 'endpoint' is missing required field 'summary'
╭─[comp.wcl:22:3]
22 │ endpoint { verb = "GET" path = "/users" }
· ─────────────────────┬────────────────────
· ╰── schema violation
╰────
…or add a slot the component never declared:
× field 'method' is not declared by schema 'endpoint'
╭─[comp.wcl:22:60]
22 │ endpoint { verb = "GET" path = "/users" summary = "x" method = "GET" }
· ───────┬──────
· ╰── schema violation
╰────
An unfilled defaultless slot is a missing required field. An undeclared slot is an unknown field. Both are the ordinary schema violations of Schemas — there is no component vocabulary inside the language, only a decorator that tells the schema where to look. Data views covers components and wdoc_repeater from the authoring side.
A derived schema is not a declaration
The language derives the schema for endpoint lazily, from the component instance. It is deliberately absent from the document's list of type declarations, because it is not one. Anything that introspects your document by walking declarations — a generated reference page, an editor palette — will not find it. Such a tool must ask for the block schema by kind instead.
§ 9Choosing a mechanism
Three mechanisms, five rows: a lowering is one mechanism with three return vocabularies, and @native is here only so you can see why it is not yours. None of them is interchangeable with another.
| Mechanism | You write | Reaches | Checked as | Reach for it when |
|---|---|---|---|---|
| lower → Content | A WCL function | HTML, PDF, Markdown | Your @block type | The block means something a document concept already names |
| lower → Svg | A WCL function | Every target that embeds the SVG | Your @block type | The block is a picture — a diagram shape |
| lower → Html | A WCL function | HTML fully; others degrade | Your @block type | The output genuinely is HTML chrome |
| wdoc_component | Markup with named slots | Whatever its body's blocks reach | A schema derived from the slots | The block is an arrangement of blocks that exist |
| @native | Rust, inside wdoc | The registry's declared backends | Cross-checked both ways | Never, in your own document |
The table says what each mechanism is for. The order to try them in is what it cannot say. Start with a component. Move to a lower as soon as the arrangement needs a decision — a conditional, a map over a list, a computed number. Reach for Html last, and only for chrome the other three targets can afford to lose.
§ 10Where to go next
- Schemas — @block, @children, @inline and what wcl check checks, since a custom block is first of all a schema.
- Types — interfaces, unions and the extends relation the three lowering interfaces are built on.
- Functions — function literals, parameters and the return-type checking a lowering does and does not get.
- Data views — components, repeaters and partials from the authoring side.
- Visibility — @only and @except, including the backend axis that waives an uncovered native block.
- The diagram canvas — layout, anchoring and connections, for a custom SvgBlock.
- Terminals and TUI — the grid model a custom TermPrimitive draws into.