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:

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

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

_md/release.mdmarkdown
# Release 2.4

## Upgrade steps

1. Stop the writer.
2. Migrate the schema.
  1. Run `wcl check config.wcl`.
  2. Apply the migration.
3. Start the writer.

## What changed

| Area | Change | Breaking |
| --- | --- | --- |
| `parse` | Reports the byte offset. | no |
| `check` | **Rejects** unknown units. | yes |
| `fmt` | Rewrites `//` to `#`. | no |

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:

wcl
list {
  li "Plain item"
  li "With **bold** and `code`"
  li "And a [link to chapter 22](wdoc_formatting)"
}

That renders as:

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:

wcl
list {
  style = :numbered
  li "First step"
  li "Second step"
  li "Third step"
}
  1. First step
  2. Second step
  3. 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:

wcl
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"
    }
  }
}
  1. Setup
  2. Build
    1. Compile
    2. Link
  3. 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:

css
ol.wdoc-list-numbered           { counter-reset: wdoc-li; }
ol.wdoc-list-numbered > li      { counter-increment: wdoc-li; }
ol.wdoc-list-numbered > li::marker { content: counters(wdoc-li, ".") ". "; }

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:

TargetSub-item markerMechanism
HTML2.1CSS counters on ol.wdoc-list-numbered
PDF2.1.The collector joins the parent's number as a prefix
Markdown1.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

BlockFieldTypeMeaning
liststyleListStyle?:bullet (default, <ul>) or :numbered (<ol>)
listitemslist<Li>The items — written as li child blocks, not as a field
listididentifier?Explicit HTML id on the <ul> / <ol>
listclasslist<utf8>?Extra classes, appended after wdoc-list-numbered
litextutf8The item text — the @inline(0) label slot
lichildrenlist<ListNode>Nested lis or lists — written as child blocks
liididentifier?Explicit HTML id on the <li>
liclasslist<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:

wcl
table {
  rows:
    | "Name"  | "Role"    | "Years" |
    | "Alice" | "**Dev**" | 3       |
    | "Bob"   | "_Ops_"   | 5       |
}
NameRoleYears
AliceDev3
BobOps5

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:

roster.wclwcl
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, $"${p.years}"] })
}
NameRoleYears
AliceDev3
BobOps5

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:

wcl
table {
  rows = [["computed", "wins"]]
  rows:
    | "pipe" | "ignored" |
}
computedwins

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.

QuestionPipe rowsComputed rows
Written asrows: then one row per linerows = […] (+ header = […])
Where the heading row comes fromThe first rowThe header field
Header-less tableNot expressible — row one is always the headerOmit header
Rows from dataNoYes — any expression of the right shape
Fed by a component slotNoYes
Reads as a grid in sourceYesOnly 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:

wcl
table {
  rows = [["Left", "Right"], ["a", "b"]]
}
LeftRight
ab

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 asHTMLMarkdown
"3"33
333
truetruetrue
:beta:betabeta
512MiB512MiB(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:

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

css
.wdoc-table th, .wdoc-table td { border-bottom: 1px solid #ccc; padding: 0.45rem 0.7rem; text-align: left; }

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:

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

ServiceRegionPort
webus-east-18080
dbus-east-15432

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:

captioned.wclwcl
@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

FieldTypeMeaning
rowslist<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.
headerlist<utf8>?The heading row of the computed form. Omit for a header-less table. Ignored by the pipe form.
ididentifier?Explicit HTML id on the <table>
classlist<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.

Questionlisttable
One entry isA statementA record
Structure comes fromNesting depthColumns, named by the header
Ordering is meaningfulWith style = :numberedOnly if you sort the rows
Entries may hold sub-entriesYes, unboundedNo — a cell is prose
Can be fed from dataNo — items are authored blocksYes, 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.

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