Websites
A website is the third thing wdoc builds. A book renders a chapter tree into a sidebar; a presentation renders pages into a slide deck; a website renders your HTML and CSS, with wdoc-rendered content dropped into holes you cut in it. You bring the design — a Figma export, a Claude artifact, a hand-built theme, a Vite bundle. wdoc brings the words.
Three mechanisms make that split work, and this chapter is those three. Named slots are the holes. The slot contract decides what a page may put in them. Head assets get the design's stylesheet and script onto the page. A fourth piece, the assets folder, ships files some other tool already built.
All of it sits on top of the ordinary template mechanism. Templates and layouts covers template, TemplateCtx and the el constructor family, which this chapter uses without re-explaining.
§ 1A website in two files
Here is a whole site: one document and one stylesheet. Save the first as main.wcl:
# main.wcl — a whole website: this document plus assets/site.css.
import <wdoc.wcl>
# Wrap a slot in `<tag class>` only when the page fills it, so an unfilled
# optional slot leaves no empty wrapper behind.
let section_for = fn(c: TemplateCtx, name: symbol, tag: utf8, cls: utf8) -> list<Html>
if len(slot_blocks(c, name)) == 0 { [] }
else { [ el(tag, [cls], slot(c, name)) ] }
template acme {
slot hero: content?
slot content: content
slot footer: content = fn(c: SlotOwner) -> list<Html> [raw("© Acme")]
render = fn(c: TemplateCtx) -> list<Html>
flatten([
wdoc_head_meta("viewport", "width=device-width, initial-scale=1"),
[
raw($<<HTML
<header class="bar"><a href="index.html">${c.title}</a></header>
HTML
),
],
section_for(c, :hero, "section", "hero"),
[
el("main", ["prose"], slot(c, :content)),
el("footer", ["foot"], slot(c, :footer)),
],
])
}
site acme {
default_template = :acme
title = "Acme"
root = true
assets = ["assets"]
stylesheets = ["assets/site.css"]
}
page index {
start = true
hero {
h1 "Ship the design with the docs"
p "The shell is HTML and CSS. The words are wdoc."
}
h2 "What lands where"
p "Everything outside a named fill lands in the reserved `content` slot."
}
page pricing {
h2 "Pricing"
p "No hero here, so no empty `<section>` either."
footer {
p "Prices exclude tax."
}
}
Save the second as assets/site.css. Its contents do not matter to wdoc — that is rather the point — so anything will do:
}
}
}
}
Build it:
$ wcl wdoc build main.wcl --out _site
wrote 2 pages
$ ls _site
_wdoc assets index.html pricing.html
The body of _site/index.html is the design, with the page's four items routed into it (line breaks added here for reading):
Acme
Ship the design with the docs
The shell is HTML and CSS. The words are wdoc.
§ 1What lands where
Everything outside a named fill lands in the reserved content slot.
© Acme
Read what became of each item. The hero { … } block filled the named slot hero. The h2 and the p filled nothing by name, so they landed in the reserved slot content. Nothing filled footer, so the default on its declaration ran instead. Meanwhile assets/site.css was linked into the <head> because the site listed it under stylesheets, and the whole assets/ folder was copied into _site/ because the site listed it under assets. Those are two separate decisions about one file, and the comparison below is mostly about not confusing them.
The body of _site/pricing.html is the other half of the experiment. It fills footer and not hero, so the footer carries its own words and the hero section is not emitted at all:
Acme
§ 1Pricing
No hero here, so no empty <section> either.
Prices exclude tax.
The rest of this chapter is those pieces one at a time.
§ 2Named slots
A slot is a hole in a layout with a name. The layout declares it, a page fills it, and the layout places it. Three different lines of code in two different files, and it is worth keeping them apart.
§ 2.1Declaring a slot
A template declares each slot with a slot item, which is neither a field nor an ordinary block:
template acme {
slot content: content # required, and the reserved name
slot hero: content? # optional
slot gallery: content<Image>? # optional, restricted to `image` blocks
slot footer: content = fn(c: SlotOwner) -> list<Html> [raw("© Acme")]
slot slide: content* # repeated — a collection template
render = fn(c: TemplateCtx) -> list<Html> wdoc_website_layout(c)
}
The grammar is slot <name>: <Type>[? | *] [= <default>], and four things vary:
- The type. For a content hole it is content. content<T> narrows what a page may put there: T names a block type or an interface, and a fill whose kind is neither T nor a descendant of it fails the build.
- ? makes the slot optional. A page need not fill it, and slot(c, :hero) then places nothing.
- *** makes the slot repeated, which turns the whole template into a collection template** — one output page assembled from every member page's copy of that slot. The built-in presentation template is exactly this (slot content: content*); see Presentations.
- = expr gives the slot a default: a fn(SlotOwner) -> list<Html> that runs only when the page leaves the slot empty. A slot with a default is optional whether or not it also carries ?.
content is a reserved name. Whatever else a layout declares, content means "everything the page did not put somewhere else", and it must be declared with the content type. Declare it as anything else and the build stops before it renders a single page:
$ wcl wdoc build main.wcl --out _site
template `acme`: reserved slot `content` must have a `content` type, found `utf8`
Two things called a slot
The same slot name: Type syntax declares the parameters and content holes of a wdoc_component. It is one language feature with two hosts: a template's slots are filled by a page, a component's by an instance. Everything below is the template half. See Data views for the component half.
§ 2.2Filling a slot
A page fills a named slot with a bare block of the same name. There is no keyword on the page side and no ordering rule — a fill may sit anywhere among the page's items:
page index {
hero {
h1 "Ship the design with the docs"
p "The shell is HTML and CSS."
}
h2 "What lands where" # not a fill — this is loose content
p "Everything outside a named fill lands in `content`."
footer { p "© Acme" }
}
Every page item that is not a named fill is loose content, and all of it fills the reserved content slot in the order you wrote it. That is the entire routing rule. A page that fills nothing by name is therefore just an ordinary wdoc page, which is why a page written for a book drops into a website layout unchanged.
A fill is a wrapper, not a block in its own right: the layout places the fill's children, and the hero { … } wrapper never appears in the output. Look again at the built HTML at the top of the chapter — the <section class="hero"> came from the layout, and only the <h1> and the <p> came from inside the fill.
§ 2.3Placing a slot
Inside render, two functions read a slot. slot(c, name) returns the placement — a list<Html> you drop wherever the design wants it. slot_blocks(c, name) returns the raw block handles, which is how you ask whether the page filled it at all:
# Place the content, unconditionally.
el("main", ["prose"], slot(c, :content))
# Place the hero only when there is one, so no empty wrapper is emitted.
if len(slot_blocks(c, :hero)) == 0 { [] }
else { [ el("section", ["hero"], slot(c, :hero)) ] }
That guard earns its two extra lines. slot(c, :hero) on an unfilled optional slot returns an empty list, and el("section", ["hero"], []) still emits <section class="hero"></section> — an empty box that your CSS will happily give four rems of padding. The built-in layout packages the same guard as website_slot_section(c, name, tag, cls); the worked example above packages it as section_for.
Both functions refuse a name the calling template did not declare. That is a render-time error rather than a contract error, because here it is the layout that is wrong, not the page:
$ wcl wdoc build main.wcl --out _site
wcl::eval::builtin_type
× '__wdoc_slot': template references slot `hero` but does not declare it
A slot's default fires here, at placement time, and only when the slot resolved to no blocks. It receives the slot's owner — the TemplateCtx for an ordinary page — so a default can read the site title, the page name, or anything else the context carries. The built-in website layout uses exactly that so an unfilled footer still reads as the site title.
§ 3The slot contract
The contract is checked once per page, before any page renders. It is a structural check: it reads the page's block kinds and the template's slot declarations, and it evaluates nothing. So a contract error names the page and the template and stops the build, rather than producing a page with something quietly missing from it.
Which template a page is checked against is the page's own template field when it has one, and the site's default_template otherwise. A page whose template does not resolve to a literal symbol is left alone: a false accusation is worse than a check deferred to render.
These are its refusals, with the message each one prints. Conditional fills below adds one more:
| What you did | The message |
|---|---|
| Left a required slot unfilled | page index: required slot content is unfilled for template acme |
| Wrote loose content, but the layout has no content slot | page index has loose content, but template acme does not declare the reserved content slot |
| Filled one slot twice | page index fills slot hero more than once |
| Filled a slot this layout lacks, but a layout the site uses declares | page index fills slot hero, but template acme does not declare it |
| Filled a slot only some unused layout declares | page index fills slot hero, but no layout used by this site declares it |
| Put the wrong kind of block in a content<T> slot | page index: slot gallery accepts Image, but found p |
| Declared the reserved content slot as something else | template acme: reserved slot content must have a content type, found utf8 |
Rows four and five are one mistake seen from two distances, and the difference tells you where to look. "Template acme does not declare it" means a layout this site really renders with does declare hero, so you have probably pointed the page at the wrong template. "No layout used by this site declares it" means the name is known only to a layout that nothing selects — a typo, or a layout you forgot to wire up.
§ 3.1Conditional fills
A page that more than one layout may render can mark a fill conditional with ?. A conditional fill the selected layout does not declare is dropped rather than refused:
page index {
hero? { p "Rendered by layouts that have a hero; dropped by the ones that don't." }
p "Loose content, as always."
}
The escape is narrow on purpose. A conditional fill is dropped only when some layout this site uses declares that slot — "this layout has no hero, but that one does". Name a slot that only an unused layout declares and the build still stops, ? or no ?:
$ wcl wdoc build main.wcl --out _site
page `index` conditionally fills slot `banner`, but no layout used by this site declares it
That is the message for a banner? { … } beside a layout of your own that declares only content: banner is a name the stdlib's website layout declares, but nothing in this site selects that layout. Conditionally fill a name that nothing in the document declares and you never reach this check at all — the language refuses the block first, as the next section explains.
§ 3.2Two checkers, two voices
A bare hero { … } inside a page is not a block kind wdoc knows. The language admits it because some top-level holder in the document declares slot hero: content — that is the only question wcl check asks, and it asks it document-wide, without deciding which holder is the active one. Everything scoped — which template, which site, filled once or twice, the right kind of child — belongs to wdoc, and runs at build time.
You can hear which checker refused you. Invent a name that no template and no component declares anywhere, and the language answers first, with a span:
$ wcl wdoc build main.wcl --out _site
wcl::eval::schema_violation
× block kind 'wibble' is not allowed inside 'page'
╭─[main.wcl:7:14]
7 │ page index { wibble { p "a" } }
· ────────┬───────
· ╰── schema violation
╰────
1 schema violation
The stdlib layouts are in scope too
import <wdoc.wcl> brings in the built-in webpage, book, presentation and website templates, and their slot names — content, banner, hero, sidebar, footer — count as declared as far as the language is concerned. So a stray hero { … } beside your own layout never produces the schema violation above. It produces one of the two "fills slot hero, but …" contract errors instead, which is the more useful answer anyway.
§ 3.3Repeaters
A wdoc_repeater inside a page is inspected structurally rather than evaluated: each named fill it generates counts as one possible fill however many elements the data has, and anything else it generates counts as possible loose content. Root-level repeaters — the ones generating whole page blocks — are deliberately left unchecked, because which layout each generated page pairs with is a render-time decision.
§ 4Head assets
A design needs its stylesheet and its script in the page <head>, and there are two ways to put them there. They are less alternatives than two owners: the site owns what belongs to every page, and the layout owns what belongs to that layout.
§ 4.1From the site block
Three site fields take lists of hrefs. stylesheets and fonts both become <link rel="stylesheet">; scripts becomes a deferred <script>:
site acme {
default_template = :acme
root = true
stylesheets = ["assets/site.css"]
scripts = ["assets/app.js"]
fonts = ["https://fonts.example/Inter.css"]
}
Every page's <head> then carries:
Each href is HTML-escaped and otherwise emitted exactly as you wrote it. wdoc does not resolve it, copy it, or check that anything is there. That is deliberate: one field takes a URL, a path under a copied assets folder and a path to a shipped file block's output, with no mode switch. fonts and stylesheets produce identical markup — two names exist so a reader of the site block can see which link is typography and which is design.
§ 4.2From the layout
A layout adds <head> content by returning an Html::Head at the top level of its fundamentals list. Five helpers wrap the cases worth naming:
| Helper | Adds to every page that layout renders |
|---|---|
| wdoc_head_stylesheet(href) | <link rel="stylesheet" href> |
| wdoc_head_script(src) | a deferred <script src> |
| wdoc_head_font(href) | a web-font <link rel="stylesheet" href> — identical to wdoc_head_stylesheet, named for intent at the call site |
| wdoc_head_meta(name, content) | <meta name content> — a description, a viewport, an og:* tag |
| wdoc_head_raw(html) | verbatim head HTML — a <base>, a preconnect, an inline <style> |
Each returns a list<Html>, so they compose into the flatten([…]) a layout is usually built from:
render = fn(c: TemplateCtx) -> list<Html>
flatten([
wdoc_head_meta("viewport", "width=device-width, initial-scale=1"),
wdoc_head_raw("<link rel=\"preconnect\" href=\"https://fonts.example\">"),
[
el("main", ["prose"], slot(c, :content)),
],
])
The site's head assets come first in the <head>, then the layout's, in the order the layout returned them. Nothing is deduplicated: name the same stylesheet in both places and it is linked twice.
Top level only
A Head is hoisted into <head> only when it is an element of the list render returned. A Head nested inside an element's children renders to nothing — it does not leak into the <body>, and it does not warn. If a head helper had no effect, check that it is not sitting inside an el(…) call.
§ 5The assets folder
stylesheets and scripts link files. assets copies them. Each entry is one relative path used twice: as a source relative to the document, and as a destination relative to this site's output directory. assets = ["assets"] reads ./assets/ and writes <out>/assets/; assets = ["design/dist"] reads ./design/dist/ and writes <out>/design/dist/. The copy is recursive and keeps every name it finds.
site acme {
root = true
assets = ["assets", "design/dist"]
stylesheets = ["assets/site.css", "design/dist/index-a3f19c.css"]
}
Verbatim is the feature. Because nothing is rewritten, assets takes a folder some other tool built — a Vite or webpack dist/, a Tailwind output, a downloaded theme — and you reference the hashed filenames it emitted by their output path. Because source and destination are the same string, keep entries inside the document's own folder: an entry that climbs out with .. writes outside the output folder too.
The folder has to exist. A missing one stops the build:
$ wcl wdoc build main.wcl --out _site
copy assets folder nope: No such file or directory (os error 2)
To ship one file rather than a folder — a download, a schema, an example config — use the file block instead. Images, videos and file assets covers it.
§ 6Five ways to get CSS and JS onto a page
There are now five, and they are easy to confuse because four of them end at the same <head>. This is the comparison:
| Way | Written as | Where it ends up | Scope | Reach for it when |
|---|---|---|---|---|
| Structured style rules | class "bar" { css = "…" } | the bundled <style> in <head> | every page of the site | the rule is yours and belongs with the document |
| A named style block | style "x" { … } + css_style(:x) | a <style> in the <body>, where you place it | every page that layout renders | the CSS belongs to one layout or one part of it |
| Site head assets | stylesheets / scripts / fonts | <head>, before the layout's | every page of the site | a file or URL that is the same everywhere |
| Layout head assets | wdoc_head_*(…) | <head>, after the site's | every page that layout renders | the layout, not the site, owns the asset |
| The assets folder | assets = ["dist"] | copied into the output tree; nothing is linked | the whole site build | another tool already built the files |
The last row is the one that catches people, because it is the only one that links nothing. assets puts bytes in the output directory and stops there. Copying a stylesheet and referencing it are two decisions, which is why the worked example names assets/site.css twice — once under assets to ship the folder, once under stylesheets to link the file. Rows one and two belong to Themes and styling, which covers them properly.
§ 7The built-in website layout
default_template = :website gets you a working shell with no layout of your own. It is a clean, theme-aware design painted from the same --wdoc-* variables as the rest of wdoc, so it follows the site's theme and accent. It declares five slots:
| Slot | Declared as | Renders as |
|---|---|---|
| content | content | <main class="ws-main"> inside the layout grid |
| banner | content? | <div class="ws-banner"> under the header |
| hero | content? | <section class="ws-hero"> under the banner |
| sidebar | content? | <aside class="ws-aside"> beside the content |
| footer | content, defaulting to the site title | <footer class="ws-footer"> |
Fill them and the shell assembles itself:
import <wdoc.wcl>
site acme {
default_template = :website
title = "Acme"
root = true
theme_toggle = true
menu {
item "Home" { page = index }
item "Pricing" { page = pricing }
}
}
page index {
start = true
banner { p "Beta — expect sharp edges." }
hero {
h1 "Acme"
p "One product, two pages."
}
h2 "Why"
p "Because."
sidebar { p "On this page" }
footer { p "© Acme" }
}
page pricing {
h2 "Pricing"
p "Free."
}
The three optional slots each vanish when unfilled, and the content grid drops to one column when sidebar is empty — pricing above renders as header, content, footer and nothing else. The footer slot's default means an unfilled footer still reads as the site title rather than as a blank bar.
§ 7.1Header, nav and footer parts
The layout is composed from named parts, and wdoc_website_layout(c) is its whole body. Copy that one function, swap a part, and you are retheming rather than starting from an empty file:
let wdoc_website_layout = fn(c: TemplateCtx) -> list<Html>
flatten([
wdoc_part_website_css(),
[ website_header(c) ],
website_slot_section(c, :banner, "div", "ws-banner"),
website_slot_section(c, :hero, "section", "ws-hero"),
[ website_body(c) ],
[ website_footer(c) ],
wdoc_part_menu_script(c.menu),
])
- wdoc_part_website_css() — the default header / hero / grid / footer styling, as a <style> placed in the body. Drop this line and every other part still works, unstyled.
- website_header(c) — the sticky <header class="ws-header">: the site title as a link home, then the nav, then the controls.
- The nav — the site's curated menu tree, via wdoc_part_menu_tree(c). With no menu block declared, the header falls back to a flat <ul class="menu"> of one link per page, so a two-page site needs no nav configuration at all. The entry matching the current page carries the current class.
- website_header_controls(c) — the right-hand cluster: wdoc_part_search_box when the site sets search = true, and wdoc_part_theme_toggle when it sets theme_toggle = true. With neither flag set the cluster is not emitted, so the header keeps the shape it had before.
- website_body(c) — <main>, plus an <aside> beside it only when the page filled sidebar. The has-aside class on the wrapper is what switches the grid to two columns.
- website_footer(c) — <footer class="ws-footer"> around the footer slot. It places the slot unconditionally, because the slot's own default guarantees something is there.
Each of those parts is an ordinary let binding in the wdoc standard library, so your own layout may call any of them. The controls cluster and the menu tree are the two worth taking: they read site configuration you would otherwise have to re-implement.
§ 8Scaffolding a project
For a real design, start from the scaffold rather than a blank file:
$ wcl init website ./my-site
$ wcl wdoc build my-site/main.wcl --out my-site/_site
It writes a five-file project: main.wcl (the imports and the site, with assets and scripts already wired), theme.wcl (a slot-declaring layout built from raw HTML), components.wcl (starter landing components — hero, feature cards, steps, footer — and their style rules), content.wcl (one page, built from those components plus named fills), and assets/app.js. Every file is meant to be edited or thrown away. The CLI covers wcl init and how it finds a template.
§ 9What a website is not
Two limits are worth stating plainly. First, everything in this chapter is HTML-only. Head assets, the copied assets folder and a raw-HTML layout mean nothing to a Markdown or PDF build, which renders page bodies and ignores templates entirely — see Output targets. Author your words as real wdoc blocks and they degrade cleanly; bury them inside raw(…) and only the website will ever show them.
Second, a slot is a hole, not a component. It has a name, an optional type restriction and a default, and that is the whole model. The moment you want parameters, repetition or reuse across pages, what you want is a wdoc_component (Data views) or a block of your own (Writing your own blocks) placed inside a slot — not a cleverer slot.
§ 10Where to go next
- Templates and layouts — template, TemplateCtx, the stdlib parts and the el constructor family this chapter builds on.
- Documents, pages and sites — the site block in full, menu, root, and the shape of the output tree.
- Themes and styling — theme, accent, and the structured class / style blocks the comparison table names.
- Presentations — repeated slots (content*) and the collection templates they turn on.
- Data views — wdoc_component, its own slot declarations, and repeaters.
- Output targets — what a website build shares with the Markdown and PDF targets, and what it keeps to itself.