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:

bare.wclwcl
import <wdoc.wcl>

site bare {
  title = "Bare"
}

page index {
  h1 "Hi"
  p "No chrome at all."
}
text
$ wcl wdoc build bare.wcl --out _site
wrote 1 page

The <body> of _site/index.html is the page and nothing else:

html
<body class="wdoc-body">
<h1 class="heading-1">Hi</h1>
<p>No chrome at all.</p>

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.

TemplateShapeReadsOutputCovered in
:webpageTitle header, sticky top navbar, reading cardmenuOne file per pageDocuments, pages and sites
:bookFixed left sidebar with a nested TOC, reading column, on-this-page railtoc, sidebar_footerOne file per pageThis chapter
:websiteHeader, optional banner / hero / sidebar sections, footermenu, named slotsOne file per pageWebsites
:presentationFull-viewport slide deck, keyboard-navigateddeckOne index.html for the whole sitePresentations

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

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

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

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

wcl
template bad {
  render = fn(c: TemplateCtx) -> list<Html> [
    el("main", ["narrow"], slot(c, :content)),
  ]
}
text
$ 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.

FieldTypeHolds
contentlist<BlockHandle>The page's authored blocks, as handles
slotslist<TemplateSlot>The resolved slot table — read it through slot / slot_blocks
titleutf8The site title
page_nameutf8The current page's name (its output file is <name>.html)
pageslist<PageRef>Every page of this site in source order — name and href
toclist<TocEntry>The resolved chapter tree; flat (one entry per page) when the site declares no toc
menulist<MenuEntry>The resolved navbar tree; empty when the site declares no menu
footerlist<FooterButton>The pinned sidebar buttons; empty when the site declares no sidebar_footer
decklist<DeckSection>The presentation slide grid; empty otherwise
memberslist<PageHandle>Member pages — non-empty only for a collection template
theme_toggleboolThe site's theme_toggle flag
searchboolThe site's search flag
home_hrefutf8Relative link back to the root site; empty when this is the root
home_titleutf8The 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.

FieldTypeHolds
reading_orderlist<TocEntry>The TOC flattened to its linked entries, in reading order
currentTocEntry?The current page's entry; none when the page is outside the TOC
current_hrefutf8<page_name>.html — the href to compare TOC entries against
previousTocEntry?The entry before current; none at the start, or outside the TOC
nextTocEntry?The entry after current; none at the end, or outside the TOC
active_pathlist<TocEntry>TOC root down to current — what a sidebar expands
headingslist<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:

guide.wclwcl
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."
}
text
$ wcl wdoc build guide.wcl --out _site
wrote 3 pages

The <body> of _site/run.html, reformatted for width:

html
<nav class="outline">
  <a class="lvl-2" href="#first-run">1 First run</a>
  <a class="lvl-3" href="#flags">1.1 Flags</a>
  <a class="lvl-2" href="#daemon-mode">2 Daemon mode</a>
</nav>
<main>
  <h1 class="heading-1" id="run">Run</h1>
  <h2 class="heading-2" id="first-run"><span class="heading-marker">§ 1</span>First run</h2>
  <p>Go.</p>
  <h3 class="heading-3" id="flags"><span class="heading-marker">§ 1.1</span>Flags</h3>
  <p>Many.</p>
  <h2 class="heading-2" id="daemon-mode"><span class="heading-marker">§ 2</span>Daemon mode</h2>
  <p>Also.</p>
</main>
<nav class="pager">
  <a class="nb" href="install.html">← Install</a>
  <a class="nb" href="reference.html">→ Reference</a>
</nav>

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:

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:

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

PartReturns
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 wantDo thisCosts you
The built-in exactly, plus somethingCall the wdoc_*_layout, flatten your own on the endNothing
The built-in minus a region, or reorderedCopy the layout's body, drop or move a partYou now own the composition, not the parts
Your own chrome entirelyCompose the shared parts you still want, write the rest with elYou 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:

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

The book sidebar pins buttons at its bottom. Declare them on the site:

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

html
<div class="book-sidebar-footer">
  <a class="book-footer-btn" href="https://example.com/repo"
     title="Source" aria-label="Source">
    <svg class="wdoc-icon"><use href="_wdoc/icons.svg#lucide-chart-network"/></svg>
  </a>
</div>

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:

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

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

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

text
wcl::eval::builtin_arity

  × 'el' expects 3 argument(s), got 4

Confusing ela with eli is not. Build these two lines side by side:

wcl
ela("a", ["link"], [["href", "x.html"]], [raw("right")]),
eli("a", ["link"], [["href", "x.html"]], [raw("wrong")]),
html
<a class="link" href="x.html">right</a>
<a>wrong</a>

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:

ConstructorRendersEscaped
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 patternsBy the engine
icon(name, cls)An icon resolved against the declared iconsets and the built-in packsn/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 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.wclwcl
# 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."
}
text
$ wcl wdoc build handbook.wcl --out _site
wrote 3 pages

The footer of _site/review.html:

html
<footer class="handbook-footer">
  <p><span>Team handbook — 3 chapters</span></p>
  <a class="edit" href="https://example.com/edit/review.wcl">Edit <span class="bold">this page</span></a>
</footer>

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