Data views
A data view is document content computed from data rather than typed out block by block. You declare the data once, as a let list or as blocks a @document schema gathers. Every card, table, chart, diagram and page then derives from that one declaration. Change the data and every view moves with it.
wdoc gives you seven mechanisms for it. The chapter takes them in the order you meet them:
- A component — a reusable fragment of markup with named slots.
- A content slot — a hole in a component that the caller fills with blocks.
- A repeater — render one body once per element of a list.
- wdoc_instance — let the data pick which component renders it.
- partial / collect — scatter tagged content, gather it in one place.
- body / project — attach content to a data record, render it elsewhere.
- type_table — build a table out of a schema.
It ends with the comparison between the seven, and with the one naming hazard that silently empties a repeater.
Every file below was run before it was written down. Type them and compare.
§ 1One worked example
Start with the whole thing. Save this as fleet.wcl. It declares a record type, two records, one component, and a page that renders three views of the same two records:
# fleet.wcl — one data set, three views of it.
import <wdoc.wcl>
# What a host is. The `@doc` text reaches the generated table at the bottom.
@block("server")
type Server {
@doc("How the fleet refers to this host.")
@inline(0) name: identifier
@doc("What it runs.") role: utf8
@doc("Current utilisation, per cent.") cpu: f64
@doc("A callout class: note, warning or error.") status: utf8
@doc("Prose about this host, rendered wherever a `project` asks for it.")
@child("body") overview: WdocAddressableBody?
}
@document
type Fleet {
@children("server") servers: list<Server>
}
server web1 {
role = "frontend"
cpu = 42.0
status = "note"
body { p $"Terminates TLS for the estate. Sitting at %." }
}
server db1 {
role = "database"
cpu = 88.0
status = "warning"
body { p $"The primary. Sitting at %." }
}
# One card, written once, stamped per host.
wdoc_component metric {
slot label: utf8
slot value: f64
slot status: utf8 = "note"
wdoc_body {
callout $"" { class = [status] body = $"Currently at **%**" }
}
}
site handbook {
default_template = :book
title = "Fleet"
toc { chapter "Fleet" { page = fleet } }
}
page fleet {
title = "Fleet"
h1 "Fleet"
wdoc_repeater { each = servers as = :s
h2 $""
project { from = s.overview }
metric { label = s.role value = s.cpu status = s.status }
}
h2 "Every host at a glance"
table {
header = ["Host", "Role", "CPU %"]
rows = map(servers, fn(s: Server) -> list<utf8> [s.name, s.role, $""])
}
h2 "What a server record holds"
type_table { type = Server }
}
$ wcl check fleet.wcl
OK
$ wcl wdoc build fleet.wcl --out _site
wrote 1 page
One page comes out, and nothing on it was written twice. The web1 and db1 headings, their prose and their cards come from the repeater. The table rows come from map. The property table at the bottom comes from the schema. Adding a third server block adds a heading, a paragraph, a card and a table row — and changes nothing else in the file.
The rest of the chapter takes that file apart.
§ 2Components
A wdoc_component is a named fragment of ordinary wdoc markup with named holes in it. Declare it at the document root — beside your pages, not inside one — with a slot per hole and a wdoc_body holding the markup:
wdoc_component metric {
slot label: utf8
slot value: f64
slot status: utf8 = "note"
wdoc_body {
callout $"" { class = [status] body = $"Currently at **%**" }
}
}
Instantiate it by writing its own name as a block, anywhere a content block is legal. The instance's fields fill the slots:
metric { label = "CPU" value = 42.0 status = "warning" }
metric { label = "Memory" value = 12.0 }
That is the live pair below — the second instance takes the status slot's default:
CPU
Currently at 42%
Memory
Currently at 12%
§ 2.1The wdoc_body
The wdoc_body is the template half of a component. It holds ordinary wdoc blocks, and it renders once per instance with that instance's slot values bound in scope.
A slot value reaches those blocks two ways. In text, through an interpolated string — $"${label}", with the $ prefix that makes the string interpolating rather than literal (Values and primitives). In a field, as a bare reference — class = [status]. Both are ordinary WCL name resolution.
A wdoc_body is declared @schemaless, and the reason is worth knowing. A body is a template, so its blocks only mean something once an instance expands them. A component used inside a diagram legitimately holds diagram shapes rather than page content, and checking the body where it sits would reject that. Whether the expansion is legal is settled where the instance sits, not where the template does.
§ 2.2The derived kind schema
metric { … } is not special syntax. WdocComponent carries @declares_kind, which is the language's word for instances of this type declare block kinds of their own. So when you write wdoc_component metric, the language derives a @block("metric") schema from that instance's slot blocks. metric { … } is then checked against it like any other block.
The derivation is mechanical. One field per slot, in declaration order. A slot carrying a ? or a default is optional. Every other slot is listed in the derived required_fields.
So the two ordinary block errors both fire on a component instance. Add either of these to fleet.wcl's page, beside the repeater rather than inside it:
$ wcl check fleet.wcl # page holds `metric { label = "Memory" }`
wcl::eval::schema_violation
× block 'metric' is missing required field 'value'
fleet.wcl: 1 schema violation
$ wcl check fleet.wcl # page holds `metric { … colour = "red" }`
wcl::eval::schema_violation
× field 'colour' is not declared by schema 'metric'
fleet.wcl: 1 schema violation
There is no component vocabulary inside the language, and no hand-rolled slot checker. An unfilled slot is a missing required field. A misspelt slot is an unknown field. See Schemas for the two errors in their ordinary setting.
A slot's declared type is not checked
Membership and required-ness are the whole of the check on a component instance. The derived schema stops there. value = "twelve" against slot value: f64 passes wcl check today. The same value in an ordinary cpu: f64 field fails with field 'cpu' declared as f64 but value is utf8. Annotate your slots anyway — the type says what the slot is for, and it is what makes ? mean optional. Do not expect the tool to hold you to it.
The scope of that check is narrower still, and this is the part to remember. An instance written inside a generator's body — a wdoc_repeater, another component, a wdoc_instance — is not checked at all. A generator is @contextual: its body only means anything once it expands, and it expands at render time, long after wcl check has finished. So the repeater in fleet.wcl will happily carry a metric with no value at all:
$ wcl check fleet.wcl # repeater holds `metric { label = s.role status = s.status }`
OK
$ wcl wdoc build fleet.wcl --out _site
wrote 1 page
The page renders, and each card reads Currently at none%. Nothing warned you. Slot mistakes surface where the instance is written flat; inside a generator they surface only in the output, so read what a generated page actually says.
§ 2.3slot and wdoc_slot
There are two spellings of a slot declaration, and the newer one is the typed one:
| Written as | Means |
|---|---|
| slot label: utf8 | Required. The type annotation is retained. |
| slot label: utf8? | Optional. Absent at the instance, the slot is none. |
| slot status: utf8 = "note" | Optional, with a fallback the body sees when the instance omits it. |
| slot content: content | A content hole, not a parameter — see Content slots. |
| wdoc_slot label | The older untyped form. Required. |
| wdoc_slot status { default = "note" } | The older form's fallback, written as a field. |
Both spellings may appear in one component, and one derivation reads both. An untyped wdoc_slot derives a permissive @schemaless utf8 field, which is the compatibility older documents rely on. Reach for slot in new work.
§ 2.4Content slots
A slot whose type is content is a different animal: it is a hole for blocks, not for a value. It derives no field on the instance schema at all. Instead the instance fills it by nesting blocks, and the body renders them.
The simplest case has one hole. wdoc_content in the body marks where the instance's own nested blocks land:
wdoc_component panel {
slot title: utf8
slot content: content
wdoc_body {
h4 $""
wdoc_content # the caller's nested blocks render here
}
}
panel { title = "A framed note"
p "Anything nested in the instance renders at wdoc_content."
list {
li "including lists"
li "and more"
}
}
Live:
A framed note
Anything nested in the instance renders at wdoc_content.
- including lists
- and more
content is the reserved name for the loose children. Every nested block the instance does not put in a named hole goes there, and wdoc_content is the marker that renders it. A component with several holes names them. Each is then both filled and rendered by its own name:
wdoc_component splitcard {
slot title: utf8
slot left: content
slot right: content?
wdoc_body {
h4 $""
left # renders whatever the instance's `left { … }` holds
right
}
}
splitcard { title = "Two holes"
left { p "LEFT" }
right { p "RIGHT" }
}
splitcard { title = "One hole"
left { p "ONLY LEFT" } # `right` is optional, so this is legal
}
A content hole is not a field, so wcl check has nothing to miss. Drop the left { … } fill from the last instance above and check still prints OK. The build is what refuses it, and it names both the component and the slot:
$ wcl check splitcard.wcl
OK
$ wcl wdoc build splitcard.wcl --out _site
component `splitcard`: required slot `left` is unfilled
$ echo $?
3
Scalar slots are the schema's business; content slots are the renderer's.
§ 3Repeating over data
A wdoc_repeater renders its body once per element of the list its each field evaluates to, binding each element to the symbol its as field names:
wdoc_repeater { each = servers as = :s
h2 $""
p $", at %"
}
The binding is an ordinary name, in scope for the whole body. So it reaches every field of every block inside, a nested component instance included. That is how the worked example stamps one card per host:
wdoc_repeater { each = servers as = :s
metric { label = s.role value = s.cpu status = s.status }
}
as is optional. Without it the binding is called it. Bindings also stack: a repeater inside a component body sees the loop variable and the enclosing component's slots. That is how a component takes a whole list through one slot and names the layout for it:
wdoc_component fleet_panel {
slot heading: utf8
slot rows: list<Server>
wdoc_body {
h3 $""
wdoc_repeater { each = rows as = :s
metric { label = s.role value = s.cpu status = s.status }
}
}
}
fleet_panel { heading = "Fleet" rows = servers }
Where the data comes from is not the repeater's business. A let list of records, a @children gather field, the result of map or filter over either — anything that evaluates to a list will do. See Lists, tensors and records and Functions.
§ 3.1What each does and does not report
The two failure modes differ, and the difference is deliberate. An each expression that fails to evaluate is a real error. The build says so, with a snippet:
$ wcl wdoc build typo.wcl --out _site
wcl::eval::unresolved_reference
× unresolved reference 'inventory'
╭─[typo.wcl:6:26]
5 │ h1 "Fleet"
6 │ wdoc_repeater { each = inventory as = :s
· ────┬────
· ╰── does not resolve
7 │ h2 $"${s.name}"
╰────
An each that evaluates to something that is not a list — a number, a record, none — renders nothing, silently. So does an empty list, which is the point: a repeater over no data is not an error. If a repeater you expected to produce rows produces none and the build stays green, its each resolved to a non-list. The gather-field name collision is the usual reason.
§ 3.2A repeater is the one iteration concept
The same block repeats at every level of a document, because every container that walks its children expands repeaters first. Four placements are worth knowing by name:
| Written inside | Body holds | Produces |
|---|---|---|
| A page | Content blocks | Repeated page content |
| A diagram or container | Diagram shapes | One shape per element — see The diagram canvas |
| The document root | page blocks | One rendered page per element; the page's interpolated label is its route |
| A toc or chapter | chapter blocks | One navigation entry per element |
The last two together give a whole catalogue site with no hand-written page or chapter boilerplate:
let containers = [
{ id: "web", name: "Web App" },
{ id: "api", name: "API" },
{ id: "db", name: "Database" },
]
# At the document root: one rendered page per element.
wdoc_repeater { each = containers as = :c
page $"cont_" {
title = c.name
h1 $""
}
}
site catalogue {
default_template = :book
toc {
chapter "Containers" {
# Inside the TOC: one entry per element, naming the generated page.
wdoc_repeater { each = containers as = :c
chapter $"" { page = $"cont_" }
}
}
}
}
A generated route is a slug
The page's interpolated label becomes its route, so it must be non-empty, hold only A-Za-z0-9_-, and be unique within its site. A colliding or space-bearing route fails the build. Build one from prose with to_lower(replace(name, " ", "-")). Links to a generated page resolve normally: the pages exist before links are checked, so a link whose target is computed from the same data works.
A repeater also composes with itself and with components in any order. A repeater inside a component body, a component instance inside a repeater body, a repeater inside a repeater: all ordinary. Diagram shapes are where this pays off most. See Connections and routing for a graph whose nodes and edges both come from one list.
§ 4Choosing the component from data
Writing a component's name as a block fixes the choice in the source. wdoc_instance instead renders the component named by the value of its component field, so each element of a list can pick its own:
let widgets = [
{ kind: "badge", text: "one" },
{ kind: "pill", text: "two" },
]
wdoc_repeater { each = widgets as = :w
wdoc_instance { component = w.kind text = w.text }
}
The instance's like-named fields fill the target's slots, falling back to each slot's default exactly as a direct instantiation does. wdoc_instance is @schemaless, so the forwarded fields need no schema of their own — which also means none of them are checked. A component value naming no declared component renders nothing.
§ 5Scatter and collect
Components and repeaters both assemble content at one spot. A partial does the opposite. It deposits tagged content wherever you happen to be writing. A collect with the same tag then gathers every deposit in the document, and renders them somewhere else in document order. It is the appendix, the glossary, the collected-sidebars pattern.
partial aside { callout "From section one" { body = "A point to collect later." } }
# ... prose, other blocks, other pages, other imported files ...
partial aside { callout "From section two" { body = "Another point." } }
# Gather every `aside` deposit here, in document order:
collect aside
A deposit is invisible where it is written. Set show_here = true to render it in place as well as at the collect site:
partial aside { show_here = true p "Pinned in place and also collected." }
Two partial ch29_aside deposits sit in this page's source immediately below this paragraph, and neither of them renders there. The collect that follows them picks up both:
From the first deposit
Written where the prose needed it.
From the second deposit
Written somewhere else entirely.
What a collect reaches
Collection is document-global, not page-local. A collect gathers matching deposits from the root document and every file a top-level import pulls in, whichever page each one sits on. Tag distinctly, or one page's sidebars turn up in another's appendix. Three limits follow. A file brought in by a block-scoped (lazy) import is not reached. A partial nested inside another partial's body belongs to that body, and is not collected separately. A collected body should carry no ids, because per-page id checks run before collection. Finally, a collect whose gathered content holds a collect of the same tag renders nothing for the inner one: the cycle is broken rather than followed.
§ 6Content that rides on a record
A partial addresses content by tag. That is the right handle when the content is a loose deposit and the gathering site is a fixed place in the document. It is the wrong handle when the content belongs to a record. Each server, each tutorial step, each API endpoint carries its own prose, and that prose has to travel with the record wherever the record is rendered.
That is what body and project are for. A body is a chunk of renderable content attached to a data record as a property. A project renders the body its from field resolves to.
@block("server")
type Server {
@inline(0) name: identifier
cpu: f64
@child("body") overview: WdocAddressableBody? # content rides on the record
}
server web1 {
cpu = 42.0
body { p $"Terminates TLS. Sitting at %." }
}
page fleet {
wdoc_repeater { each = servers as = :s
h2 $""
project { from = s.overview } # render THIS record's body
}
}
Three things about that are worth spelling out.
- A body never renders where it is written. It is not a content block, so a page renderer walks straight past it. The only way to its contents is a project.
- ${…} inside a body resolves against the record it hangs on. ${cpu} above is web1's cpu, and the rendered line reads Terminates TLS. Sitting at 42%. You are not copying the record's fields into the prose by hand.
- from carries an address, not a copy. body is declared @by_ref. So when the repeater reads a server record as data, the record's overview slot reifies to a reference rather than to inlined content. s.overview is therefore the address of that server's fragment, and one line of project renders a different body per element.
§ 6.1Addressing a body
How you name a body depends on how it is attached.
| Attached as | Addressed by | Write the body as |
|---|---|---|
| A single @child("body") slot | The slot: from = s.overview | body { … } |
| A @children("body") list | The slot: from = t.notes, which renders every body in the list, in order | body intro { … } |
| Standalone at the document root | Its label, through the root's bodies field | body glossary { … } |
A body in a list, or one standing alone at the root, needs the @inline(0) label — that is what the path segment matches. A body filling a single @child slot is already named by the slot, so its label may be dropped.
A project written at page scope — outside any repeater, with nothing bound — names the full path from a root gather field instead:
project { from = tutorials.deploy.steps.1.detail }
Records carrying a body nest to any depth, and a numeric @inline(0) label is fine. Read that path as Documents, fields and blocks reads any other: tutorials is the root gather field, deploy and 1 are labels, steps is a nested gather field, detail is the body slot.
steps.1 is a label, not an index
steps.1 matches the step labelled 1. It is not the first element of the list. Relabel the steps 10, 20, 30 and steps.1 resolves to nothing, while steps.10 resolves to the first one. Path segments address blocks by label everywhere in WCL; a body slot is no exception.
§ 6.2When a project has nothing to render
from must evaluate to a body reference. Anything else is a build error naming the block:
$ wcl wdoc build fleet.wcl --out _site
wcl::eval::user_error
× error: `project`'s `from` did not resolve to a body reference; it must
│ name an addressable `body` (e.g. a `@by_ref` property of the data being
│ generated from)
╭─[fleet.wcl:6:3]
6 │ project { from = "nope" }
· ────────────┬────────────
· ╰── error raised here
╰────
A from that resolves to a path holding something other than a body reports the path it followed instead — project target tutorials.deploy did not resolve to a body. And a body that projects itself terminates rather than recursing: the inner projection renders nothing and the outer one completes.
§ 7Tables from the schema
The last data view takes its data from the schema rather than from records. type_table { type = T } reflects a type's fields into a documentation table, so a reference page cannot drift from the declaration it documents.
@block("server")
type Server {
@doc("How the fleet refers to this host.")
@inline(0) name: identifier
@doc("What it runs.") role: utf8
@hidden internal_seed: u32?
@child("body") overview: WdocAddressableBody?
}
page reference {
type_table { type = Server }
}
Reflecting this chapter's own Ch29Server type gives:
| Property | Type | Required | Description |
|---|---|---|---|
| name | identifier | yes | How the fleet refers to this host. |
| role | utf8 | yes | What it runs. |
| cpu | f64 | yes | Current utilisation, per cent. |
| status | utf8 | yes | A callout class: note, warning or error. |
Child blocks
| Slot | Accepts | Multiple | Description |
|---|---|---|---|
| overview | body | no | Prose about this host, rendered wherever a project asks for it. |
Two tables come out. The first lists the scalar properties — name, type, whether the field is required, and the description. The second is headed Child blocks. It lists the type's @child / @children slots, with what each accepts and whether it holds one block or a list. It is omitted entirely when the type has no block slots. Fields inherited through extends are included, own fields first.
Two decorators steer the output, and both are authored on the schema, not on the table:
| Decorator | Effect |
|---|---|
| @doc("…") | The description column. A language built-in — see Decorators. |
| @hidden | Drops the field from the generated table. Declared by wdoc. |
Function-typed fields are dropped automatically, which is what keeps every block type's lower hook out of its own reference table. See Writing your own blocks for what lower is.
§ 7.1Reflecting a whole document
block_reference { type = MyDoc } walks a @document type's block slots. It emits an h3 plus a type_table for each, so a document schema documents its own top-level tags with no hand-maintained list:
import <wdoc.wcl>
@document
type MyDoc {
@children("widget") widgets: list<Widget>
@child("settings") settings: Settings
}
@block("widget") type Widget { @inline(0) id: identifier @doc("Pixels wide.") width: u32 }
@block("settings") type Settings { @doc("UI theme") theme: utf8 }
page reference {
block_reference { type = MyDoc } # one heading + property table per block
}
Both components are thin wrappers over reflection builtins. type_fields gives the field list, child_types a type's block-slot element types, and decorator_arg the @block("…") kind name that becomes each heading. See Builtins. Drop to the repeater directly when you want a different layout:
wdoc_repeater { each = child_types(MyDoc) as = :b
type_table { type = b }
}
A union or interface slot documents as one name
child_types resolves a slot to its declared element type. A slot that accepts a union or an interface resolves to that type's name. type_table documents a single concrete type, so such a slot is not expanded into a table per member. Document the members individually.
§ 8The gather-field name collision
Here is the one hazard that will cost you an afternoon. Every data view above reads a gather field, and the names a data-view author reaches for are exactly the names wdoc's own @document schema already uses.
@document schemas compose per namespace. Importing <wdoc.wcl> and declaring your own root @document is not a conflict but a merge, and that is what lets you add top-level tags of your own (Schemas). The merged schema is one flat name space, though. A gather field declared on both sides resolves to only one of the two declarations.
import <wdoc.wcl>
@document
type App {
@children("component") pages: list<Component> # `pages` is already wdoc's
}
@block("component")
type Component { @inline(0) id: identifier name: utf8 }
component web { name = "Web" }
component api { name = "API" }
site s { default_template = :book title = "T" toc { chapter "C" { page = c } } }
page c {
title = "Components"
h1 "Components"
wdoc_repeater { each = pages as = :x
h2 $""
}
}
$ wcl check clash.wcl
warning: gather field 'pages' of @document 'App' (this document) collides with
'pages' declared by @document 'wdoc.Site' (<wcl-system>/wdoc/core.wcl) — the
merged document schema resolves 'pages' to only one declaration, so the other
schema's gathered blocks silently vanish; rename one field
clash.wcl: 1 warning
OK
$ wcl wdoc build clash.wcl --out _site
wrote 1 page
The build is green and the page is empty. Two component blocks are in the file; the repeater rendered nothing at all, because each = pages resolved to the other declaration. Nothing failed — the check is a warning, and the repeater's silent-on-a-non-list rule swallowed the rest.
The names to avoid are the ones wdoc's own @document already gathers. At the time of writing that is pages, sites, components, partials, bodies, generators, includes, agents, styles, themes, templates, patterns, iconsets, tilesets, classes, bases, font_faces, media and keyframes. Do not learn the list — a wdoc release may add to it. Notice instead how many of them are the natural name for a data-view gather.
The fix is one word. Name yours sw_components, catalog_pages, service_bodies. Then let the warning be the thing that tells you, because it always will.
§ 9Choosing a mechanism
Seven tools, and the choice between them is nearly always decided by one question: where does the content live, and what addresses it?
| Mechanism | Content lives | Addressed by | Reach for it when |
|---|---|---|---|
| wdoc_component | In the component's wdoc_body | The component's name | One layout, many call sites with different values |
| wdoc_content / a content slot | At the call site | The slot's name | The component frames content the caller writes |
| wdoc_repeater | In the repeater's body | Nothing — it is written where it renders | One body, once per element of a list |
| wdoc_instance | In some component's body | A value | The data chooses which component renders it |
| partial / collect | Wherever you wrote the deposit | A tag | Content is scattered; one place assembles it |
| body / project | Inside a data record | A reference to that record's slot | Prose belongs to a record and travels with it |
| type_table | In the schema | A type | The table is the declaration, restated |
Three of those pairs look alike and are not:
- wdoc_instance against a component instance. Both render a component. A component instance names it in the source; wdoc_instance names it with a value. Use the plain form until an element of your data has to decide.
- partial / collect against body / project. Both write content in one place and render it in another. A partial is addressed by a tag, so many deposits share one name and the collect site takes all of them. A body is addressed by a reference to the record that owns it, so one project inside a repeater renders a different fragment per element. If the content belongs to a record, it wants a body; if it belongs to a stretch of the narrative, it wants a partial.
- A component with a list slot against a repeater. A repeater iterates. A component with a list-typed slot iterates and names the layout, so the call site reads fleet_panel { rows = servers } instead of a loop. Reach for the component the second time you write the same loop.
The mechanisms compose without ceremony, because they are all block expansion. A repeater inside a component body inside a repeater is ordinary. A project inside a component body inside a diagram is ordinary. Anything that walks a container's children expands generators first, so a generated block participates exactly as an authored one does.
§ 10Where to go next
- Lists, tensors and records — the list values a repeater iterates, and map / filter / flatten for shaping them.
- Schemas — @document merging, gather fields, and the collision warning above in its own setting.
- Decorators — @doc, and what a decorator is before wdoc adds @hidden.
- Builtins — type_fields, child_types, decorator_arg and the rest of the reflection surface.
- Lists and tables — a table's header / rows fields, which is where a computed table's cells land.
- Charts — feeding mapped data straight into a chart's value fields.
- The diagram canvas and Connections and routing — repeater-generated shapes and computed edges.
- Writing your own blocks — when a component is not enough and you want a block kind of your own.