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:

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:

text
$ 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.wclwcl
# 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: $"`${e.verb} ${e.path}`", 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:

text
$ 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:

text
$ 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:

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:

text
× 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:

wcl
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.

InterfaceLowering returnsRendered asDeclared in
ContentBlocklist<Content>Page content — prose, media, apparatuscore.wcl
SvgBlocklist<Svg>Shapes inside a diagramcore.wcl
TermPrimitivelist<TermFundamental>Cells of a terminal gridterminal.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:

text
 × 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:

chip.wclwcl
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:

text
$ 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.

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:

GroupVariantCarries
ProseHeadinglevel: u8, text
ProseParagraphtext (inline patterns apply)
ProseListitems, each with its own nested blocks
ProseTablerows, optional header and caption
ProseCodesource, optional language and filename
ProseCalloutkind, heading, body: list<Content>
ProseColumnscolumns, each its own content list
MediaImagesource, alt, caption, size
MediaVideosource, poster, title, size
MediaFilepath, label
MediaMathlatex, display
MediaDrawingshapes: list<Svg> — the page-level SVG bridge
MediaTerminallines — the resolved screen
ApparatusTocresolved entries, not a marker to expand
ApparatusFootnotesresolved notes
ApparatusChapterHeadertitle, kicker, and the meta line
PresentationFragmentbody — a step-revealed group on a slide
PresentationSpeakerNotesbody — 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:

promo.wclwcl
@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:

text
<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:

text
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.

recurse.wclwcl
@block("badge")
type Badge extends ContentBlock {
  @inline(0) text: utf8
  id: identifier?
  lower = fn(b: Badge) -> list<Content> [
    Content::Paragraph { text: $"**${b.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:

text
$ 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:

text
<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.

wcl
@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:

text
× 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:

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:

text
$ 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:

wcl
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.wclwcl
# 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 $"`${verb} ${path}`"
    p $"${summary}" { 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."
  }
}
text
$ 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:

wcl
wdoc_component panel {
  slot heading: utf8
  slot body:    content

  wdoc_body {
    h3 $"${heading}"
    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:

text
 × 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:

text
 × 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.

MechanismYou writeReachesChecked asReach for it when
lowerContentA WCL functionHTML, PDF, MarkdownYour @block typeThe block means something a document concept already names
lowerSvgA WCL functionEvery target that embeds the SVGYour @block typeThe block is a picture — a diagram shape
lowerHtmlA WCL functionHTML fully; others degradeYour @block typeThe output genuinely is HTML chrome
wdoc_componentMarkup with named slotsWhatever its body's blocks reachA schema derived from the slotsThe block is an arrangement of blocks that exist
@nativeRust, inside wdocThe registry's declared backendsCross-checked both waysNever, 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