wdoc · explanation
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.
Two ways a block renders
wdoc renders a block exactly one of two ways, and the block's type says which:
- lower is 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, and it is the path open to you.
- @native means the renderer implements the kind in Rust and never calls a lowering. That 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 {
╰────
A 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 build api.wcl --out _md --type markdown
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 a page accepts. @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 what the HTML backend derives from that number, 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. 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 to avoid that collision. Prefix your kinds. Templates and layouts covers slots.
What 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.
Overriding 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 the override rarely. It is the escape hatch for the one instance that does not fit, not a substitute for a second kind.
The 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), and 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, the 1-based grid coordinates, because a terminal cell has to sit somewhere. See Terminals and TUI.
A diagram shape
You declare a custom shape the same way, 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.
The semantic content IR
Content is the vocabulary a ContentBlock lowering returns. It deserves a section of its own, because its design is a deliberate decision that constrains what you can write.
It is the target-neutral document vocabulary. lib/content.wcl declares one variant per document concept, and every output target reads that one declaration. 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, and that divergence is what 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 non-HTML targets read the class system 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 | language, then either source or a source_file to read (with anchor / lines / dedent), and an optional 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.
Union tag, not variant name
The renderer classifies a lowered value by the union it belongs to, never by the name of its variant. The distinction matters because the names collide on purpose. Both Content and the HTML vocabulary described next declare Paragraph, Table and Math. 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 schema checker keeps them apart for you.
The 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 cases: 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.
The stdlib builds its own landing-page components this way, and Html is the right tool when you lay 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. Expect it to drop away elsewhere.
Lowering 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, then 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. The recursion stops at the limit and leaves a marker where the content would have been:
<body class="wdoc-body">
<!-- wdoc: lowering depth limit reached -->
</body>
In practice you 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.
Native blocks
@native is the other half of lower. It says: wdoc's Rust dispatch renders this kind, and no lowering runs.
@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. Omit it and the kind covers every target. That 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 a mismatch in either direction is an error:
- 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 a new Rust arm for a kind needs its registry row and its @native declaration, in the same change. Neither half alone builds.
Uncovered 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.
A native block used on a target it does not cover is a build error, not a silent drop:
$ wcl wdoc build preview.wcl --out _md --type markdown
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.
Components: 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. You instantiate the component 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, so 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 the component. 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, such as a generated reference page or an editor palette, will not find it. Such a tool must ask for the block schema by kind instead.
Choosing 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.
Where 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.