Data Views
A *data view* renders document content — cards, tables, charts, diagrams — from a WCL data structure rather than hand-authored blocks. It's how you present data gathered about systems: declare the data once, then derive every view from it. The primary tool is a component: a reusable fragment of ordinary wdoc markup with named slots.
Every example draws from one inventory:
let inventory = [
{ name: "web-1", role: "frontend", cpu: 42.0, mem: 60.0, up: true, status: "note" },
{ name: "db-1", role: "database", cpu: 88.0, mem: 75.0, up: true, status: "warning" },
{ name: "cache", role: "cache", cpu: 25.0, mem: 40.0, up: false, status: "error" },
]
§ 1Components
Declare a wdoc_component with wdoc_slots and a wdoc_body of ordinary markup. Reference slots in any $"…${slot}…" interpolated string or as a bare identifier in a field (class = [status]). A slot with a default is optional. Instantiate the component by its own name:
CPU
Currently at 42%
Memory
Currently at 88%
wdoc_component dv_metric {
wdoc_slot label
wdoc_slot value
wdoc_slot status { default = "note" }
wdoc_body {
callout $"" { class = [status] body = $"Currently at **%**" }
}
}
// ... then, anywhere a block is allowed:
dv_metric { label = "CPU" value = 42 status = "warning" }
dv_metric { label = "Memory" value = 88 } // status defaults to "note"
Interpolating slots into text
Slot values land in text via WCL's $"…" interpolated strings — note the $ prefix. A plain "…" string is literal. Bare references in a field (like class = [status]) need no prefix.
§ 2Repeating over data
wdoc_repeater renders its body once per element of each, binding the element to the symbol named by as. Combined with a component, it stamps one card per data row:
web-1
Currently at 42%
db-1
Currently at 88%
cache
Currently at 25%
wdoc_repeater { each = inventory as = :h
dv_metric { label = h.name value = h.cpu status = h.status }
}
A repeater needs no component — its body is just markup with the loop variable in scope:
wdoc_repeater { each = inventory as = :h
h3 $""
p $"CPU % · Mem %"
}
§ 3Repeaters inside components
A slot can hold a whole list, and a wdoc_repeater inside the body can iterate it. Bindings stack, so the loop body sees both the loop variable and the component's other slots:
§ 3.1Fleet
web-1
Currently at 42%
db-1
Currently at 88%
cache
Currently at 25%
wdoc_component dv_hosts {
wdoc_slot heading
wdoc_slot rows // a list-typed slot
wdoc_body {
h3 $""
wdoc_repeater { each = rows as = :h
dv_metric { label = h.name value = h.cpu status = h.status }
}
}
}
dv_hosts { heading = "Fleet" rows = inventory }
A wdoc_repeater is the single iteration concept at every level. At the document root, give it a page block as its body and it emits one rendered page per element — the page's interpolated label becomes the route (<name>.html). Inside a toc (or a chapter), give it a chapter block and it emits one navigation entry per element. So a whole catalogue site — one page per service, table, or endpoint — comes from data, with no hand-authored page or chapter boilerplate:
let containers = [
{ id: "web", name: "Web App" },
{ id: "api", name: "API" },
{ id: "db", name: "Database" },
]
// One rendered page per element. The interpolated label is the route.
wdoc_repeater { each = containers as = :c
page $"cont_" {
sites = [:docbook]
title = c.name
h1 $""
p $"See the [Web App](cont_web) container." // links resolve against
} // the generated page set
}
site docbook {
default_template = :book
toc {
chapter "Containers" {
// One TOC entry per element, linking to the generated page.
wdoc_repeater { each = containers as = :c
chapter $"" { page = $"cont_" }
}
}
}
}
Routes must be slug-safe and unique
A generated route is its interpolated label, so it must be non-empty, contain only the characters A-Za-z0-9_-, and be unique within its site — colliding or space-bearing routes are a build error. Build a slug from prose with to_lower(replace(s, " ", "-")). A cross-page link whose target is computed from the same data resolves automatically, because the generated pages exist before links are checked.
§ 5Render by reference
Instantiating a component writes its name as a block, so the *choice* of component is fixed in the source. A wdoc_instance instead renders the component named by the value of its component field — so a repeater can emit a *different* component per element. The instance's like-named fields fill the target's slots (falling back to each slot's default):
CPU
Currently at 42%
Memory
Currently at 91%
let widgets = [
{ kind: "dv_metric", label: "CPU", value: 42, status: "ok" },
{ kind: "dv_metric", label: "Memory", value: 91, status: "warning" },
]
wdoc_repeater { each = widgets as = :row
// `component` is data, so each element picks its own component.
wdoc_instance { component = row.kind label = row.label value = row.value status = row.status }
}
A repeater works in any container
wdoc_repeater / wdoc_instance / component instances expand wherever a container iterates its children — a page, a diagram, a wf_* wireframe frame, a node_table, or the CSS-collection pass. A repeater inside a device frame composes its widgets from data; a repeater that emits class blocks generates stylesheet rules from data. Whatever a generator's body contains is what gets spliced in.
§ 6Content slots (layout wrappers)
A wdoc_content block in a component body marks where the instance's *own* nested blocks render — so a component can frame arbitrary content:
§ 6.1Notes
Anything nested in the instance renders at wdoc_content.
- including lists
wdoc_component dv_panel {
wdoc_slot title
wdoc_body {
h3 $""
wdoc_content // the caller's nested blocks render here
}
}
dv_panel { title = "Notes"
p "Anything nested in the instance renders at wdoc_content."
list { li "including lists" li "and more" }
}
§ 7Partials (scatter & collect)
Components and repeaters compose content at one spot. A partial does the opposite: it lets you *scatter* tagged content throughout a document — even across imported files — and have one place gather it. It's the appendix / glossary / collected-sidebars pattern.
A partial tags a body of blocks; a collect with the same tag gathers every matching partial — across the whole document and its imported files — and renders their bodies, in document order, at the collect site. A partial is invisible where it's defined unless you set show_here = true.
Some prose lives between the deposits…
…and the collect below gathers both of the dv_aside deposits above, in order:
From section one
A point worth collecting later.
From section two
Another collected point.
// Scatter tagged deposits anywhere — different blocks, even imported files:
partial aside { callout "From section one" { body = "A point to collect later." } }
// ... prose, other blocks ...
partial aside { callout "From section two" { body = "Another point." } }
// Gather every `aside` partial here, in document order:
collect aside
Set show_here = true to render a partial both where it's defined *and* where it's collected:
partial aside { show_here = true p "Pinned in place and also collected." }
Scope & limits
Collection is document-global: a collect gathers matching partials from the root document and every file pulled in by a top-level import — so tag deposits distinctly to avoid cross-page bleed. Partials in block-scoped (lazily imported) files aren't reached, nested partials inside a partial body aren't separately collected, and a collected body should avoid ids (per-page id checks run before collection). A collect whose collected content contains another collect of the same tag is a no-op (the cycle is broken).
§ 8Content fragments on data (body & project)
Partials gather content by tag. A body does something different: it attaches a chunk of renderable content to a data record as a *property* — without that record being a renderable block — and a project renders it elsewhere by reference. This is the pattern when you generate pages from structured data and want each record to carry its own prose.
Declare your own block type with a @child("body") slot, author the content inside each record, then project it from a repeater. Because body is @by_ref, reading a record as data turns its body slot into a reference, so from = s.overview resolves to *that* record's fragment — and ${…} inside the body resolves against the record:
@document
type Infra { @children("server") servers: list<Server> }
@block("server")
type Server {
@inline(0) name: identifier
region: utf8?
@child("body") overview: WdocAddressableBody? // content rides on the record
}
server web01 { region = "us-east"
body { p $"Frontend in ." } // NOT a renderable block here
}
server web02 { region = "eu-west"
body { p $"Frontend in ." }
}
page fleet {
wdoc_repeater { each = servers as = :s
h2 $""
project { from = s.overview } // render THIS record's body
}
}
Addressing
A single @child("body") slot is addressed by its slot, so the body needs no name. A body in a @children("body") list, or one declared standalone at the document root, is addressed by its @inline(0) name — give each a label (body intro { … }). A direct page-scope project names the full path from a root field (from = servers.web01.overview). The record carrying a body may be nested (a step inside a tutorial) and may use a numeric label — a tstep 1's body resolves via st.body from a nested repeater, or statically via from = tuts.t1.steps.1.body. Note steps.1 matches the step *labelled* 1, not the first element — it's label matching, not positional indexing. A body never renders where it's declared, only where projected; a body that projects itself terminates (the inner self-reference is dropped).
§ 9Tables from data
Set a table's rows to a list of cell-lists — mapped from your data — with an optional header row. utf8 cells run through the inline-pattern engine (so **bold**, :icons:, links work); other scalars stringify:
| Host | Role | CPU % |
|---|---|---|
| web-1 | frontend | 42% |
| db-1 | database | 88% |
| cache | cache | 25% |
table {
header = ["Host", "Role", "CPU %"]
rows = map(inventory, fn(h: DvHost) -> list<utf8> { [h.name, h.role, $"%"] })
}
Wrap a table in a component to reuse the layout, feeding the rows through a slot. Name the slot apart from the table's own rows field (a field that references its own name is a cycle, and renders nothing):
wdoc_component host_table {
wdoc_slot data
wdoc_body { table { header = ["Host", "Role", "CPU %"] rows = data } }
}
host_table { data = map(inventory, fn(h: DvHost) -> list<utf8> { [h.name, h.role, $"%"] }) }
§ 10Charts from data
Charts read their data from value fields, so you map the inventory straight into them — no wrapper needed:
bar_chart {
categories = map(inventory, fn(h: DvHost) -> utf8 { h.name })
series = [
{ name: "cpu", values: map(inventory, fn(h: DvHost) -> f64 { h.cpu }) },
{ name: "mem", values: map(inventory, fn(h: DvHost) -> f64 { h.mem }) },
]
}
§ 11Connections from data
A wdoc_repeater inside a diagram generates one shape per data element; give each a data-derived id. Then set the diagram's edges to a computed list — mapped from the data's own relationships — and layout = :layered (or :force) positions the graph automatically. Connect by id: each edge's source/destination names a node's id.
type DvSvc { key: utf8 name: utf8 deps: list<utf8> }
let services = [
{ key: "web", name: "Web", deps: ["api"] },
{ key: "api", name: "API", deps: ["db", "cache"] },
{ key: "db", name: "DB", deps: [] },
{ key: "cache", name: "Cache", deps: [] },
]
// One edge per (service → dependency) pair, derived from the data.
let service_links = flatten(map(services, fn(s: DvSvc) -> list<Edge> {
map(s.deps, fn(d: utf8) -> Edge { { source: s.key, destination: d } })
}))
diagram { width = 460 height = 300 layout = :layered
wdoc_repeater { each = services as = :s
process $"" { id = s.key } // node label from data, id = key
}
edges = service_links
}
Name the binding apart from edges
Assign the computed edges from a differently-named binding (edges = service_links), not edges = edges — a field that references its own name is a cycle and renders nothing.
§ 12Documenting types
The built-in type_table component documents a schema type by reflecting it — no hand-maintained field lists. type_table { type = Image } renders a table of the type's properties (name, type, whether it's required, and a description), including fields inherited via extends; a second "Child blocks" table lists its @child / @children slots. It's built on the type_fields reflection builtin.
Descriptions and visibility are authored on the schema itself:
| Decorator | Effect |
|---|---|
| @doc("…") | Sets the description shown for the field. |
| @hidden | Drops the field from the generated table. |
Function-typed fields (every block's lower hook) are skipped automatically. This very page's Pages and Images field tables are generated this way.
§ 13Documenting your own schema
type_table reflects *any* type, so it documents the blocks you declare just as well as wdoc's built-ins. Declare your own root @document alongside import <wdoc.wcl> (the two schemas merge), give each block type a @block("…") kind and @doc descriptions, then reflect them:
import <wdoc.wcl>
@document
type MyDoc {
@children("project_meta") metas: list<ProjectMeta>
@child("settings") settings: Settings
}
@block("project_meta") type ProjectMeta { @inline(0) id: identifier @doc("the owner") owner: utf8 }
@block("settings") type Settings { @doc("UI theme") theme: utf8 }
page reference {
h2 "Top-level blocks"
block_reference { type = MyDoc } // one heading + property table per block
}
block_reference { type = MyDoc } walks the document's @child / @children slots and emits an h3 (the block's @block kind) plus a type_table for each — no hand-maintained list, and the reference can't drift from the schema. It's a thin wrapper over the child_types reflection builtin, which returns the element type references of a type's block slots. Drop to the repeater directly when you want a different layout:
wdoc_repeater { each = child_types(MyDoc) as = :b
type_table { type = b }
}
Union and interface slots
child_types resolves a @child / @children slot to its declared element type. A slot that accepts a union or interface resolves to that type's name; since type_table documents a single concrete type / interface, such slots aren't expanded into a table per variant — document those members individually.
§ 14The pattern
| Tool | Use it for |
|---|---|
| wdoc_component | A reusable fragment of wdoc markup with slots — cards, panels, sections. The everyday tool. |
| wdoc_repeater | Rendering a body (or a component) once per element of a data list. |
| wdoc_content | A component that wraps arbitrary caller-provided content. |
| partial + collect | Scattering tagged content across a document (or imported files) and gathering it by tag at one site — appendices, glossaries, collected sidebars. |
| table computed rows | A data-driven <table> — table { header = […] rows = map(data, …) }. |
| wdoc_repeater + computed edges | A data-driven diagram — repeater-generated nodes (id from data) wired by a computed edges list, auto-positioned by :layered/:force. |
| Chart value fields | Feeding mapped data straight into bar_chart / line_chart / pie_chart. |
| type_table | Auto-documenting a schema type's fields — reflected via type_fields, described with @doc / @hidden. |
| block_reference | Auto-documenting every top-level block your @document declares — a heading + type_table per block, via child_types. |