Lists and tables
Two blocks put a sequence of things on a page. A list puts them one under another; a table puts them on a grid. Neither renders through the semantic content IR. Both are @native, and Why both are native says why that is a property of how you write them rather than an omission.
This chapter covers list and li, nesting and numbering, and table in both of its authoring forms. It covers what a cell may hold. It also covers the two things a table has no field for: alignment and captions.
Every example below was built before it was written down. Type them and compare.
§ 1One file with both
Save this as release.wcl. It is the whole worked example — a list and a table in one page, with nothing else in the file but the site that carries them:
import <wdoc.wcl>
site notes {
default_template = :book
title = "Release notes"
}
page release {
title = "Release 2.4"
h1 "Release 2.4"
h2 "Upgrade steps"
list {
style = :numbered
li "Stop the writer."
li "Migrate the schema." {
li "Run `wcl check config.wcl`."
li "Apply the migration."
}
li "Start the writer."
}
h2 "What changed"
table {
rows:
| "Area" | "Change" | "Breaking" |
| "`parse`" | "Reports the byte offset." | "no" |
| "`check`" | "**Rejects** unknown units."| "yes" |
| "`fmt`" | "Rewrites `//` to `#`." | "no" |
}
}
Build it, and read the same page out as Markdown:
$ wcl wdoc build release.wcl --out _site
wrote 1 page
$ wcl wdoc markdown release.wcl --out _md
wrote 1 page
_md/release.md is byte-for-byte this:
1. 2. 1. 2. 3.
Two things to notice already. The ` wcl check config.wcl inside an li and the Rejects inside a cell both survived: item text and cell text run through the inline-pattern engine, exactly as a p does. And the Markdown sub-steps restarted at 1., where the HTML build numbers them 2.1 and 2.2`. Numbering covers that, and it is the first of three places where these two blocks do not agree across targets — cells and alignment are the other two.
§ 2Lists
A list block holds li items. It is a bullet list by default — a <ul> in HTML — and each li carries its text as the block's inline label:
list {
li "Plain item"
li "With **bold** and `code`"
li "And a [link to chapter 22](wdoc_formatting)"
}
That renders as:
- Plain item
- With bold and code
- And a link to chapter 22
The text is the @inline(0) slot of Li, so li "text" is a labelled block with an empty body, not a field assignment. Everything Text and formatting documents works inside it: emphasis, inline code, links, icons and inline math.
§ 2.1Numbered lists
Set style = :numbered for an ordered list — an <ol> carrying the wdoc-list-numbered class:
list {
style = :numbered
li "First step"
li "Second step"
li "Third step"
}
- First step
- Second step
- Third step
style takes the two-member ListStyle symbol set — :bullet and :numbered. Omitting the field is the same as writing :bullet.
§ 2.2Nesting
There are two ways to put a list inside a list, and the difference is the sublist's style.
Write an li directly inside an li and the sublist inherits the enclosing list's style. Write a whole list block inside an li and the sublist sets its own:
list {
style = :numbered
li "Setup"
li "Build" {
li "Compile" # inherits :numbered
li "Link"
}
li "Run" {
list { # a bulleted sublist inside a numbered list
li "Foreground"
li "Background"
}
}
}
- Setup
- Build
- Compile
- Link
- Run
- Foreground
- Background
The schema behind that is one interface. Li.children is declared @children(ListNode), and ListNode is the interface both Li and ListBlock extend — so an li admits further lis, further lists, or a mix, and nothing else. Nesting is unbounded in depth.
A sublist is inside its item, not after it
The nested <ul> / <ol> is emitted inside the parent <li>, before its closing tag. That is what makes it a sublist rather than a second list, and — as Why both are native explains — it is also why an li cannot be a block that lowers on its own.
§ 2.3Numbering
A numbered sublist reads 2.1, 2.2 in the HTML build, and no Rust code counts the items to get there. The three rules the stdlib injects into every page's <head> do it with CSS counters:
}
}
}
Each nested ol.wdoc-list-numbered resets its own counter, and counters(…, ".") joins every level in scope — which is why an li-under-li sublist has to inherit the parent's style for the joined number to mean anything. Hierarchical numbering therefore costs the renderer no per-item state at all.
It also does not survive a target that has no CSS. The three backends number a nested ordered list three ways:
| Target | Sub-item marker | Mechanism |
|---|---|---|
| HTML | 2.1 | CSS counters on ol.wdoc-list-numbered |
| 2.1. | The collector joins the parent's number as a prefix | |
| Markdown | 1. | An indented ordered list; the renderer restarts each level |
Bullet markers diverge the same way and matter less: HTML leaves them to the browser, the PDF backend cycles •, ◦, ▪ by depth, and Markdown writes - at every level. Output targets covers what else changes between the four.
§ 2.4list and li fields
| Block | Field | Type | Meaning |
|---|---|---|---|
| list | style | ListStyle? | :bullet (default, <ul>) or :numbered (<ol>) |
| list | items | list<Li> | The items — written as li child blocks, not as a field |
| list | id | identifier? | Explicit HTML id on the <ul> / <ol> |
| list | class | list<utf8>? | Extra classes, appended after wdoc-list-numbered |
| li | text | utf8 | The item text — the @inline(0) label slot |
| li | children | list<ListNode> | Nested lis or lists — written as child blocks |
| li | id | identifier? | Explicit HTML id on the <li> |
| li | class | list<utf8>? | Classes on the <li> |
§ 3Tables
A table renders a grid: an optional heading row over body rows. It has two authoring forms, and which one you get is decided by whether the block carries a rows field.
§ 3.1Pipe rows
The first form is WCL's pipe-table syntax under a rows: header. The colon is the whole distinction — rows: opens a table, rows = assigns a field. The first row is the heading row:
table {
rows:
| "Name" | "Role" | "Years" |
| "Alice" | "**Dev**" | 3 |
| "Bob" | "_Ops_" | 5 |
}
| Name | Role | Years |
|---|---|---|
| Alice | Dev | 3 |
| Bob | Ops | 5 |
This is the form to reach for when the data is written by hand at the point of use. It reads as a table in the source, which is most of its value.
§ 3.2Computed rows
The second form sets rows as an ordinary field — a list of cell-lists — with an optional header list beside it. Use it when the rows come from data rather than from your keyboard:
type Person { name: utf8 role: utf8 years: u32 }
let people = [
{ name: "Alice", role: "Dev", years: 3u32 },
{ name: "Bob", role: "Ops", years: 5u32 },
]
# ... inside a page:
table {
header = ["Name", "Role", "Years"]
rows = map(people, fn(p: Person) -> list<utf8> { [p.name, p.role, $""] })
}
| Name | Role | Years |
|---|---|---|
| Alice | Dev | 3 |
| Bob | Ops | 5 |
Because rows is a field like any other, anything that produces a list<list<utf8>> can fill it: a map over a gather field (Builtins, Functions), a let computed at the top of the file, or a wdoc_slot on a component. That last one is the reason the form exists — a component can take a table's rows as a parameter, which the pipe form cannot express. See Data views.
§ 3.3The two forms do not mix
A table carrying both is not an error and not a merge. The presence of a rows field selects the computed path, and the pipe rows in the same block are then never read:
table {
rows = [["computed", "wins"]]
rows:
| "pipe" | "ignored" |
}
| computed | wins |
Note what the header did there. The pipe form's first row is the header; the computed form's header is the separate header field. Switching a table from pipe rows to computed rows without moving the first row into header silently demotes it to a body row.
| Question | Pipe rows | Computed rows |
|---|---|---|
| Written as | rows: then one row per line | rows = […] (+ header = […]) |
| Where the heading row comes from | The first row | The header field |
| Header-less table | Not expressible — row one is always the header | Omit header |
| Rows from data | No | Yes — any expression of the right shape |
| Fed by a component slot | No | Yes |
| Reads as a grid in source | Yes | Only as far as your literal does |
§ 3.4Header-less tables
Omit header on the computed form and no <thead> is emitted at all — the whole table is body rows. There is no way to say this with pipe rows, because the first pipe row is the header by definition:
table {
rows = [["Left", "Right"], ["a", "b"]]
}
| Left | Right |
| a | b |
The Markdown backend cannot follow that one. GFM's table syntax has no header-less form, so a header-less table emits an empty heading row (| | |) above the separator. The HTML and PDF outputs are header-less as written.
§ 3.5Cells
A cell holding a string runs through the inline-pattern engine, so bold, italic, inline code, links, icons and inline math all work inside one, exactly as they do in an li. A cell holding any other scalar is stringified.
Stringified is doing real work in that sentence, and it is the one place a table will surprise you. The HTML build prints the value's own spelling; the Markdown and PDF backends read the cell through a numeric conversion that a symbol, and any value carrying a unit, do not survive intact:
| Cell written as | HTML | Markdown |
|---|---|---|
| "3" | 3 | 3 |
| 3 | 3 | 3 |
| true | true | true |
| :beta | :beta | beta |
| 512MiB | 512MiB | (empty) |
Quote your cells
A table is prose laid out on a grid, not a typed record — nothing checks a cell's type and nothing is declared for it to be checked against. Write every cell you care about as a string literal and the four targets agree; leave a unit-carrying or symbol value bare and they do not. "512MiB" is a cell. 512MiB is a value that happens to be sitting in one.
One more cell rule follows from the syntax rather than from rendering. A bare | splits the row, so any cell containing a pipe must be a quoted string:
table {
rows:
| "Pattern" | "Matches" |
| "`a | b`" | "either branch" |
}
Write the pipe as itself inside the quotes. There is no \| escape — the lexer rejects it with invalid escape '\|'. The Markdown backend escapes the character back to \| on its way out, so the round trip is fine; it is only the WCL source that needs the quotes.
Cell count is not checked either. A short row and a long row both build, producing a ragged <tr>; the header row's length does not constrain the body. This mirrors the pipe rows in a data document, which are equally unchecked, and for the same reason — a pipe row is parsed as a row of expressions, not validated against a declared shape.
§ 3.6Alignment
table has no alignment field. Every cell is left-aligned by the base rule the stdlib injects with the rest of the table styling:
}
Change it with a class. Declare one with a nest selector picking the column, list it in the table's class field, and the cascade does the rest — a class rule is emitted after the base rules, so it wins without !important:
class "num-right" {
nest "th:nth-child(3), td:nth-child(3)" { css = "text-align: right;" }
}
table {
class = ["num-right"]
rows:
| "Service" | "Region" | "Port" |
| "web" | "us-east-1" | "8080" |
| "db" | "us-east-1" | "5432" |
}
The third column right-aligns. A class block is site-wide, so this chapter declares the same rule under the prefixed name wdoc-ch25-right to keep a generic num-right out of the other 41 chapters — the table below is that one:
| Service | Region | Port |
|---|---|---|
| web | us-east-1 | 8080 |
| db | us-east-1 | 5432 |
Two consequences worth stating plainly. The column is selected by position, so inserting a column moves the alignment with the index rather than with the data. And alignment is CSS, so it exists in the HTML build and nowhere else: the Markdown backend writes | --- | separators with no alignment colons, and the PDF backend lays every cell out left-aligned. Themes and styling covers class, base and nest in full.
§ 3.7Captions
table has no caption field either, and this one has a way out. The semantic content IR's Content::Table variant does carry a caption, and a block of your own that lowers to it reaches the <caption> element that the built-in table never emits:
@block("captioned_table")
type CaptionedTable extends wdoc.ContentBlock {
@inline(0) caption: utf8
header: list<utf8>
rows: list<list<utf8>>
lower = fn(t: CaptionedTable) -> list<wdoc.Content> [
wdoc.Content::Table { rows: t.rows, header: t.header, caption: t.caption }
]
}
# ... inside a page:
captioned_table "Table 1 — ports in use" {
header = ["Service", "Port"]
rows = [["web", "8080"], ["db", "5432"]]
}
That emits <caption>Table 1 — ports in use</caption> as the first child of the <table> in HTML, a caption line under the grid in the PDF, and a trailing paragraph after the pipe table in Markdown. Writing your own blocks covers lower, the Content union and what a custom ContentBlock costs you.
Until you want one, a p under the table says the same thing and costs nothing.
§ 3.8table fields
| Field | Type | Meaning |
|---|---|---|
| rows | list<list<utf8>>? | Body rows. Present as a field ⇒ the computed form; written as rows: ⇒ pipe rows, first row is the header. @schemaless, so a cell of any scalar type passes. |
| header | list<utf8>? | The heading row of the computed form. Omit for a header-less table. Ignored by the pipe form. |
| id | identifier? | Explicit HTML id on the <table> |
| class | list<utf8>? | Extra classes, appended after wdoc-table |
TableBlock, not Table
The type behind the block is named TableBlock, because Table is the reserved type name the @table decorator claims — see Schemas. The block kind you write is still table.
§ 4Choosing between them
A list and a table are not stylistic alternatives. Each item of a list is one thing, and the only structure is its depth. Each row of a table is one thing described along the same axes as every other row, and the header names those axes. Reach for a table the moment you find yourself writing the same three words at the start of every item.
| Question | list | table |
|---|---|---|
| One entry is | A statement | A record |
| Structure comes from | Nesting depth | Columns, named by the header |
| Ordering is meaningful | With style = :numbered | Only if you sort the rows |
| Entries may hold sub-entries | Yes, unbounded | No — a cell is prose |
| Can be fed from data | No — items are authored blocks | Yes, via the computed form |
| Renders as | <ul> / <ol> | <table> |
The last row of that comparison is the one that decides most cases. A list's items are blocks you write; a table's rows can be a value you compute. When the content is generated, the choice is already made for you.
§ 5Why both are native
Every other prose block in wdoc — p, h1, code, callout, math — declares a lower returning nodes of the semantic content IR, and each backend renders that one declaration. list and table declare @native instead. A block declares exactly one of the two; both, or neither, fails the build.
@native is not a to-do. It is a claim about the block's authored value, and the two blocks make it for two different reasons.
- list needs its li / list block structure. A lowering returns a flat list<Content> — siblings. A sublist has to end up inside its parent's <li>, and a WCL lowering has no way to reach into the node it just returned and put a later one in it. The nesting is in the authored block tree, so the block tree is what the renderer has to walk.
- table's pipe rows are in the block's syntax, not in its value. rows: is a table item — WCL's own pipe-row form — and a table item is not a field. A lower receives the typed block value, where a field would be; the pipe rows are not there to receive. Only the computed form's rows field is a value at all, and a block declares one rendering, not one per authoring form.
So the reason is the same shape in both cases and it is not about HTML: what you write is a structure the language holds in the block tree, and a lowering is handed a value. The Content::List and Content::Table variants still exist, and — as Captions showed — a block of yours may lower to either. What @native says is that these two blocks, as authored, cannot.
Native is a coverage claim, not an exemption
@native names the targets that implement the kind, and it is cross-checked both ways against the Rust dispatch table: a target claimed but not implemented, or implemented but not claimed, fails the build. list and table both claim all three — HTML, PDF and Markdown — so neither has to be waived out of any build. See Output targets.
§ 6Where to go next
- Text and formatting — the inline patterns that run inside every li and every string cell.
- Themes and styling — class, base, nest, and the cascade the alignment recipe leans on.
- Data views — components, repeaters and the slots that feed a computed table's rows.
- Output targets — what HTML, PDF and Markdown builds each do with the same page.
- Writing your own blocks — lower, the Content union, and @native.
- Documents, fields and blocks — the pipe-row syntax as a language feature, and the other twelve item forms.