Templates and layouts
A page holds content. A template holds everything around it: the sidebar, the navbar, the footer, the previous/next links. wdoc ships four, and none of them is special. A template is a WCL function from a context record to a list of HTML fundamentals, and a site selects yours exactly the way it selects book.
This chapter covers the four built-ins, the template block and the TemplateCtx a template receives. It then covers the page_metadata(c) builtin a template navigates with, the wdoc_part_* functions the built-ins compose, and the el family you write the rest of the markup with.
Every file and every command below was run before it was written down. Type them and compare.
§ 1What a template is
Start without one. This document declares a site that names no template at all:
import <wdoc.wcl>
site bare {
title = "Bare"
}
page index {
h1 "Hi"
p "No chrome at all."
}
$ wcl wdoc build bare.wcl --out _site
wrote 1 page
The <body> of _site/index.html is the page and nothing else:
Hi
No chrome at all.
Now add default_template = :book to the site block and rebuild. A fixed sidebar, a reading column, an "on this page" rail and a pagination footer now wrap the same two blocks. The <h1> also grows id="hi", and that detail is the mechanism in miniature: the template path stamps the anchor ids and the § section markers, not the block. A bare page has neither.
So a template is not decoration. It is the thing that decides what a page's blocks become and what surrounds them.
A template is a function, not a file
There is no template directory, no include path and no inheritance. A template is a template block in an ordinary .wcl file — a render field holding fn(c: TemplateCtx) -> list<Html>. The four built-ins live in the embedded stdlib (import <wdoc.wcl>) and are found by name in exactly the same lookup that finds yours.
§ 2The built-in templates
Four template blocks ship in the stdlib, and each reads its own navigation block off the site. Each also has a chapter of its own. This one covers what they have in common.
| Template | Shape | Reads | Output | Covered in |
|---|---|---|---|---|
| :webpage | Title header, sticky top navbar, reading card | menu | One file per page | Documents, pages and sites |
| :book | Fixed left sidebar with a nested TOC, reading column, on-this-page rail | toc, sidebar_footer | One file per page | This chapter |
| :website | Header, optional banner / hero / sidebar sections, footer | menu, named slots | One file per page | Websites |
| :presentation | Full-viewport slide deck, keyboard-navigated | deck | One index.html for the whole site | Presentations |
:webpage and :website overlap, and the difference is what a page may fill. webpage offers one hole: the page's loose content. website adds named slots — banner, hero, sidebar, footer — plus <head> assets, so a page can place content in more than one region of the layout.
§ 3Selecting a template
Two fields select one, and the page wins:
- default_template on the site block — a symbol naming a template. Applies to every page of that site.
- template on a page block — the same symbol, for one page only. Overrides the site's default.
- Neither — the page renders bare, as bare.wcl above did.
site handbook {
default_template = :webpage # every page …
title = "Handbook"
}
page changelog {
template = :book # … except this one
h1 "Changelog"
}
A symbol naming no template block stops the build before any page is written. Set default_template = :blog in bare.wcl with no template blog declared anywhere and the whole build is one line:
$ wcl wdoc build bare.wcl --out _site
unknown template "blog"
§ 4The template block
The block itself is small. A label, one or more slot declarations, and render:
template narrow {
slot content: content
render = fn(c: TemplateCtx) -> list<Html> [
el("main", ["narrow"], slot(c, :content)),
]
}
render is an ordinary function value — see Functions. It takes the context described below and returns list<Html>, the HTML fundamental vocabulary. Those fundamentals become the page <body>; the <head> and the stylesheet are the renderer's, not the template's. A top-level Head fundamental is the exception, and it hoists its children into <head> instead — Websites covers that.
§ 4.1The content slot
A page's authored blocks reach the template through a slot. One slot name is reserved: content holds every block the page wrote loose in its body, and slot(c, :content) places them.
The declaration is slot <name>: <type>, and the reserved slot's type must be content itself. A trailing ? or * marks the slot optional or repeated and leaves the type alone, so the presentation template's slot content: content* still satisfies the rule.
Placing the slot is not enough — the template has to *declare* it. Drop the slot line from narrow above and keep the slot(c, :content) call:
template bad {
render = fn(c: TemplateCtx) -> list<Html> [
el("main", ["narrow"], slot(c, :content)),
]
}
$ wcl wdoc build bad.wcl --out _site
page `index` has loose content, but template `bad` does not declare the reserved `content` slot
Websites covers the rest of what a slot declaration can do: extra named slots, ? for optional, * for repeated, and a default fallback. Two functions are worth carrying here. slot(c, :name) returns list<Html> ready to place. slot_blocks(c, :name) returns the raw handles, so a template can ask whether a slot is filled before it emits a wrapper for it.
Handles, not HTML
c.content and slot_blocks(...) hand you read-only BlockHandles, not rendered markup. A template may inspect a handle's kind, read its concrete block fields and walk its children. It may also reorder or filter the list. Nothing renders until the renderer walks the returned tree, and that happens after render has finished. A template therefore never forces a page body it decided not to place. wdoc_blocks(handles) places a list of handles you assembled yourself.
§ 5TemplateCtx
One record arrives at render. Every built-in template reads it and nothing else.
| Field | Type | Holds |
|---|---|---|
| content | list<BlockHandle> | The page's authored blocks, as handles |
| slots | list<TemplateSlot> | The resolved slot table — read it through slot / slot_blocks |
| title | utf8 | The site title |
| page_name | utf8 | The current page's name (its output file is <name>.html) |
| pages | list<PageRef> | Every page of this site in source order — name and href |
| toc | list<TocEntry> | The resolved chapter tree; flat (one entry per page) when the site declares no toc |
| menu | list<MenuEntry> | The resolved navbar tree; empty when the site declares no menu |
| footer | list<FooterButton> | The pinned sidebar buttons; empty when the site declares no sidebar_footer |
| deck | list<DeckSection> | The presentation slide grid; empty otherwise |
| members | list<PageHandle> | Member pages — non-empty only for a collection template |
| theme_toggle | bool | The site's theme_toggle flag |
| search | bool | The site's search flag |
| home_href | utf8 | Relative link back to the root site; empty when this is the root |
| home_title | utf8 | The root site's title, for that link's text |
Note what is not there: no current chapter, no previous page, no next page, no heading list. Those are derived, not carried, and the next section is why.
Note also that the context is per-site, not per-document. c.pages and c.toc describe the site the current page belongs to. A document declaring three sites calls render with three different contexts.
§ 6Reading order with page_metadata
A book sidebar needs to know which chapter is current. A pagination footer needs the previous and next pages. An "on this page" rail needs the current page's own headings. All three are questions about position. Answer them naively and you evaluate every other page's body — exactly what a lazy document exists to avoid.
page_metadata(c) is the answer. It takes the context and returns a PageMetadata record. It indexes each site's shared toc value once, memoised, and it never asks the evaluator for another page's body: the reading order comes from the TOC tree the renderer already resolved, and the heading list comes from the current page's own authored handles.
| Field | Type | Holds |
|---|---|---|
| reading_order | list<TocEntry> | The TOC flattened to its linked entries, in reading order |
| current | TocEntry? | The current page's entry; none when the page is outside the TOC |
| current_href | utf8 | <page_name>.html — the href to compare TOC entries against |
| previous | TocEntry? | The entry before current; none at the start, or outside the TOC |
| next | TocEntry? | The entry after current; none at the end, or outside the TOC |
| active_path | list<TocEntry> | TOC root down to current — what a sidebar expands |
| headings | list<OnPageHeading> | The h2 / h3 headings of the page's content slot — see the warning below |
A grouping chapter — one with no page — is in the tree and in the active_path, but never in the reading_order. That is what makes previous/next skip section headings instead of linking to nothing.
Here is a template that uses nothing but page_metadata, and the output it produces:
import <wdoc.wcl>
let outline_row = fn(h: OnPageHeading) -> Html
ela("a", [format("lvl-{}", h.level)], [["href", format("#{}", h.id)]],
[raw(format("{} {}", h.number, h.title))])
let neighbour = fn(e: TocEntry, arrow: utf8) -> list<Html>
if e == none { [] }
else { [ ela("a", ["nb"], [["href", e.href]], [raw(format("{} {}", arrow, e.title))]) ] }
template outlined {
slot content: content
render = fn(c: TemplateCtx) -> list<Html> {
let m = page_metadata(c);
flatten([
[ el("nav", ["outline"], map(m.headings, outline_row)) ],
[ el("main", [], slot(c, :content)) ],
[ el("nav", ["pager"], flatten([
neighbour(m.previous, "←"),
neighbour(m.next, "→"),
])) ],
])
}
}
site guide {
default_template = :outlined
title = "Guide"
toc {
chapter "Part one" {
chapter "Install" { page = install }
chapter "Run" { page = run }
}
chapter "Reference" { page = reference }
}
}
page install {
h1 "Install"
p "Get it."
}
page run {
h1 "Run"
h2 "First run"
p "Go."
h3 "Flags"
p "Many."
h2 "Daemon mode"
p "Also."
}
page reference {
h1 "Reference"
p "Everything."
}
$ wcl wdoc build guide.wcl --out _site
wrote 3 pages
The <body> of _site/run.html, reformatted for width:
1 First run
1.1 Flags
2 Daemon mode
Run
§ 1First run
Go.
§ 1.1Flags
Many.
§ 2Daemon mode
Also.
← Install
→ Reference
The pager skipped the grouping chapter "Part one". It linked install and reference instead: the two entries either side of run in the flattened order, across a section boundary.
§ 6.1Headings, ids and § numbers
Look at the hrefs the outline emitted and the ids the headings carry. They match, and nothing in the template arranged that.
Two passes produced them. page_metadata read the authored handles to build the rail. A page-wide pass over the rendered HTML then stamped the id and the § marker. Both walk the same numbering sequence, so they agree by construction rather than by luck. Three rules follow:
- An id is slugified from the heading text. It is lowercased, and each run of non-alphanumerics becomes one -. A second heading with the same text gets -2, then -3. A heading that writes its own id keeps it.
- Only h2 and h3 carry a number. An h1, h4, h5 or h6 gets its id stamped and nothing more — no § marker, no rail entry.
- The sequence counts once per page, not once per slot. It follows the order the template *places* slots, not the order the page *wrote* them. Place content and then a sidebar, and the sidebar's headings number after every content heading.
headings is the content slot only
The numbering spans every slot. m.headings does not. The builtin reads c.content and nothing else, so a heading the page wrote into a named slot never reaches the list. It still gets its § number and its anchor in the HTML. Take the sidebar of rule three: its heading numbers after the content headings, and the rail still shows only the content ones. Build your own list from slot_blocks(c, :name) when a named slot has to appear in a rail.
Bind the record; do not chain off the call
Member access needs a reference on its left, not a call. wcl check passes len(page_metadata(c).reading_order), and the build then fails with expected a reference, got call. Bind the record first — let m = page_metadata(c); inside a { … } block expression, as outlined does above. Then read m.reading_order. The rule governs every expression, not just this builtin; see Expressions and operators.
§ 7Template parts
The built-in templates are not privileged code. Each is a composition of small let-bound functions the stdlib also exports to you, and each built-in's whole body is one of them:
# From the stdlib. This is the entire `book` template.
let wdoc_book_layout = fn(c: TemplateCtx) -> list<Html> {
let m = page_metadata(c);
flatten([
wdoc_part_book_css(),
wdoc_part_sidebar_with_metadata(c, m),
wdoc_part_book_content_with_metadata(c, m),
wdoc_part_book_rail_with_metadata(m),
])
}
template book {
slot content: content
render = fn(c: TemplateCtx) -> list<Html>
wdoc_book_layout(c)
}
Every part returns list<Html>, so composing them is flatten([…]). The catalogue:
| Part | Returns |
|---|---|
| wdoc_part_home_link(c, cls) | The back-to-root link on a sub-site; empty on the root |
| wdoc_part_search_box(enabled) | The search input and its results dropdown; gate it on c.search |
| wdoc_part_theme_toggle(enabled) | The light/dark button and its script; gate it on c.theme_toggle |
| wdoc_part_webpage_css() | The webpage stylesheet |
| wdoc_part_header(c) | <header class="site-header"> with the site title |
| wdoc_part_navbar(c) | <nav class="site-nav"> — home link, search, menu |
| wdoc_part_menu_tree(c) | The nested <ul class="menu"> on its own |
| wdoc_part_menu_script(c.menu) | The one-time dropdown-toggle script; empty with no menu |
| wdoc_part_content(c) | <main class="site-main"> holding the content slot |
| wdoc_part_book_css() | The book stylesheet |
| wdoc_part_sidebar(c) † | The fixed left sidebar: title, home link, search, toggle, TOC, footer buttons |
| wdoc_part_toc_tree(c) | The nested <ul class="book-toc"> on its own |
| wdoc_part_sidebar_footer(c) | The pinned footer buttons; empty with no sidebar_footer |
| wdoc_part_book_content(c) † | The reading column plus its pagination |
| wdoc_part_book_rail(c) † | The right-hand "on this page" rail; empty on a page with no h2/h3 |
| wdoc_part_pagenav(c) | The previous/next pair on its own |
| wdoc_part_website_css() | The website stylesheet |
| wdoc_part_presentation_css() | The deck stylesheet |
| wdoc_part_deck(c) | The slide grid built from c.deck |
| wdoc_part_deck_chrome() | The deck progress bar, counter and nav hints |
| wdoc_part_presentation_player() | The bundled keyboard player script |
† Each of these three has a second form taking the metadata: wdoc_part_sidebar_with_metadata(c, m), wdoc_part_book_content_with_metadata(c, m) and wdoc_part_book_rail_with_metadata(m). The pair does one thing. The _with_metadata form reads the PageMetadata you already bound instead of calling page_metadata again. Use it when your template touches more than one of the three, as wdoc_book_layout does. Note that the rail's form takes m alone.
Four whole layouts sit above the parts: wdoc_webpage_layout(c), wdoc_book_layout(c), wdoc_website_layout(c) and wdoc_presentation_layout(c).
§ 7.1Overriding, appending, copying
There is no extends and no block-override syntax. There are three moves, and they cover the ground between them.
| You want | Do this | Costs you |
|---|---|---|
| The built-in exactly, plus something | Call the wdoc_*_layout, flatten your own on the end | Nothing |
| The built-in minus a region, or reordered | Copy the layout's body, drop or move a part | You now own the composition, not the parts |
| Your own chrome entirely | Compose the shared parts you still want, write the rest with el | You own the layout |
The middle one is the common case, and it is a three-line function. Here is the book layout with the right-hand rail dropped:
let narrow_layout = fn(c: TemplateCtx) -> list<Html>
flatten([
wdoc_part_book_css(),
wdoc_part_sidebar(c),
wdoc_part_book_content(c),
])
Parts resolve by bare name
Every part, every layout and every member of the el family is a plain let binding brought in by import <wdoc.wcl>. They are reached by bare name, and there is no namespace to hide behind. A let of your own named el, raw, wdoc_part_sidebar or wdoc_book_layout shadows the stdlib one for the rest of that scope. Prefix your own helpers.
§ 8Sidebar footer buttons
The book sidebar pins buttons at its bottom. Declare them on the site:
site handbook {
default_template = :book
title = "Team handbook"
toc { chapter "Welcome" { page = index } }
sidebar_footer {
button "Source" {
href = "https://example.com/repo"
icon = "lucide.chart-network"
}
}
}
A button takes its label inline, then a link and an optional icon name. The link is either page — an in-site page name, validated, so an unknown one fails the build — or href, any URL. page wins when a button gives both.
Each button reaches the template as a FooterButton on c.footer. The renderer resolves the icon first, so the field already holds SVG markup: embed it with raw rather than resolving it yourself.
title="Source" aria-label="Source">
The built-in book renders the button icon-only and puts the label in title and aria-label, so a row of buttons stays a row. A button with no icon falls back to its label as visible text, so it is never empty. An icon name that resolves to nothing does the same — which is the failure mode to watch for, because it is silent. Icons covers the packs and the naming.
Only the book template reads c.footer. Declaring sidebar_footer on a webpage or website site is not an error; it simply renders nothing, unless your own template places wdoc_part_sidebar_footer(c).
§ 9The el constructor family
Below the parts is the markup itself. A template builds Html values, and the long form is a record literal:
Html::Element { tag: "div", class: ["book-toc-row"], children: kids }
Written out by the hundred, that is mostly punctuation. The el family is the same thing with the field names dropped:
el("div", ["book-toc-row"], kids)
Each constructor is exactly its long form, not a wrapper that normalises anything. A test in the crate holds it to that: it builds the same tree both ways in one document and compares the two rendered bodies byte for byte. That equivalence is what let the stdlib move to the family without changing a byte of its output.
§ 9.1Three element constructors
There is one element variant and three constructors for it. The reason is a language fact, not a design preference: a WCL parameter list is fixed at declaration. There are no default arguments and no named arguments, so every call fills every parameter. One constructor carrying both id and attrs would force none, [] on most call sites, so each of the two gets a constructor of its own.
| Constructor | Long form |
|---|---|
| el(tag, cls, kids) | Html::Element { tag, class, children } |
| ela(tag, cls, attrs, kids) | Html::Element { tag, class, attrs, children } |
| eli(tag, id, cls, kids) | Html::Element { tag, id, class, children } |
attrs is a list of [name, value] pairs — [["href", p.href], ["title", p.label]] — and the renderer escapes every value. id takes an identifier, so write a literal one as a string: eli("main", "main", ["site-main"], kids).
An empty class or attrs list emits no attribute at all, not class="". An unset optional passed straight through does the same: el("p", t.class, kids) with t.class unset renders exactly as an omitted field does.
The parameter lists cannot say so, because ? in a parameter list is a parse error. Every wdoc fn therefore annotates its optionals as though they were required and lets the none flow through.
§ 9.2The ela / eli trap
ela and eli take four arguments each. WCL checks argument arity and never argument types, so arity is the one positional mistake it catches — and it cannot separate these two. Confusing el with ela is caught:
wcl::eval::builtin_arity
× 'el' expects 3 argument(s), got 4
Confusing ela with eli is not. Build these two lines side by side:
ela("a", ["link"], [["href", "x.html"]], [raw("right")]),
eli("a", ["link"], [["href", "x.html"]], [raw("wrong")]),
right
wrong
The second call handed ["link"] to id and the attribute list to class. The renderer dropped both, the build reported nothing, and the link stopped being a link. Pick the constructor by what you are passing. When an element needs an id and attributes, write the record.
§ 9.3The leaves
Five constructors build the non-element variants:
| Constructor | Renders | Escaped |
|---|---|---|
| raw(html) | Verbatim, pre-rendered HTML — a resolved icon, an inline <script> | No |
| inl(text) | One prose run through the inline-pattern engine — bold, code spans, links, :icons:, $math$ | By the engine |
| para(cls, spans) | <p class> of spans, each run through the inline patterns | By the engine |
| icon(name, cls) | An icon resolved against the declared iconsets and the built-in packs | n/a |
| css_style(name) | A named top-level style block, in place, as <style>…</style> | n/a |
inl does not wrap itself — write el("p", [], [inl(txt)]). raw is the escape hatch and the hazard in one: it is the only way to place markup a block already rendered, and the only way to inject markup that was never checked. Text and formatting covers what the inline engine understands. css_style is how every wdoc_part_*_css() works — the stylesheet is a style block, and the part is one call.
§ 9.4When to write the long form
The family covers the HTML element vocabulary and stops there. Two things keep the named-field literal, deliberately:
- The other value families. These are the Svg shapes a diagram block draws and the Content values an ordinary block's lower returns — see Writing your own blocks. Both are field-shaped, so a positional constructor over them would be the same record with its field names deleted. Recall that arity is the only positional mistake WCL catches. Transpose two of an SVG label's four interchangeable f64s and it renders silently wrong, where the record raises a shape mismatch. The saving is smaller there than on elements, and the risk is larger.
- Combinations the family does not name — an element carrying an id *and* attributes, a Paragraph with an id, a Head, a Table.
The long form is legal everywhere and never wrong. The family is a shorthand for the shapes you write a hundred times, and it is worth exactly that much.
§ 10A worked layout
Everything above in one file: the book layout with the rail dropped and a footer added. The composition is a copy of the stdlib one, three parts kept and one gone, and the footer is built with the el family.
# handbook.wcl — the stdlib book layout, minus the rail, plus a footer.
import <wdoc.wcl>
template handbook {
slot content: content
render = fn(c: TemplateCtx) -> list<Html> {
let m = page_metadata(c);
flatten([
wdoc_part_book_css(),
wdoc_part_sidebar_with_metadata(c, m),
wdoc_part_book_content_with_metadata(c, m),
[ el("footer", ["handbook-footer"], [
para([], [format("{} — {} chapters", c.title, len(m.reading_order))]),
ela("a", ["edit"],
[["href", format("https://example.com/edit/{}.wcl", c.page_name)]],
[inl("Edit **this page**")]),
]) ],
])
}
}
site handbook {
default_template = :handbook
title = "Team handbook"
theme_toggle = true
toc {
chapter "Welcome" { page = index }
chapter "Practices" {
chapter "Code review" { page = review }
chapter "On call" { page = oncall }
}
}
sidebar_footer {
button "Source" {
href = "https://example.com/repo"
icon = "lucide.chart-network"
}
}
}
page index {
title = "Welcome"
h1 "Welcome"
p "Everything the team agreed to write down."
}
page review {
title = "Code review"
h1 "Code review"
h2 "What to look for"
p "Correctness first, then names."
h3 "Naming"
p "A name that lies costs more than a slow function."
h2 "How fast"
p "One working day."
}
page oncall {
title = "On call"
h1 "On call"
h2 "Rotation"
p "One week at a time."
}
$ wcl wdoc build handbook.wcl --out _site
wrote 3 pages
The footer of _site/review.html:
Team handbook — 3 chapters
Edit this page
Three chapters, not four: the toc has four entries, and Practices is a grouping heading with no page, so it never reaches the reading order. page_metadata did that, not the footer.
Watch the line breaks
Write one block per line inside a page. h1 "Hello" p "World." on a single line is one block, not two: the parser reads p and "World." as two more labels on the h1. wcl check prints OK, wcl fmt prints the line back unchanged, and the paragraph is simply absent from the built page.
§ 11Where to go next
- Documents, pages and sites — the site block, page blocks, toc and menu, and the output tree the templates write into.
- Websites — named slots, slot defaults and fallbacks, <head> assets, and the website template in full.
- Presentations — the deck grid, collection templates and repeated slots.
- Themes and styling — style blocks, class rules and the --wdoc-* variables the built-in layouts consume.
- Writing your own blocks — the other half of the Html vocabulary: a block's lower, and when a block is @native instead.
- Functions — the fn literals, block expressions and let bindings a render is built from.