wdoc · reference
Code
A code block is a source listing: a language tag, some text, and an optional filename. It is the block a technical document uses most, and it is deliberately thin. It measures nothing, draws no chrome, and knows nothing about the page around it. It hands the renderer a listing and the language it is written in, and each output target draws that listing the way its own medium wants.
The text comes from one of two places. Write it into the document with source, or name a file with source_file and let the build read it. The second is what keeps a manual honest about code that lives somewhere else.
This chapter covers the block and its fields, reading a listing from a file, the language tags that resolve to a grammar, and what HTML, Markdown and PDF each make of one listing. Every example below was built and read back before it was written down.
One listing, three outputs
Save this as listing.wcl. It is a whole document, with the import, a site, a page, and one code block:
# listing.wcl — one page, one listing.
import <wdoc.wcl>
site manual {
title = "Manual"
}
page install {
title = "Install"
h1 "Install"
p "Fetch the archive, unpack it, and put the binary on your path:"
code bash {
filename = "install.sh"
source = <<'SH'
set -euo pipefail
curl -fsSL https://example.com/tool.tar.gz -o tool.tar.gz
tar -xzf tool.tar.gz
install -m 755 tool/bin/tool /usr/local/bin/tool
SH
}
}
The example uses two heredocs. The outer one is the WCL you are reading. The inner one is the listing itself. Both are raw heredocs, <<'TAG' with the tag quoted, so nothing inside either is escaped or interpolated. That is the form to reach for every time. See Values and primitives for the whole heredoc grammar.
Build it three ways:
$ wcl check listing.wcl
OK
$ wcl wdoc build listing.wcl --out _site
wrote 1 page
$ wcl wdoc build listing.wcl --out _md --type markdown
wrote 1 page
$ wcl wdoc build listing.wcl --out manual.pdf --type pdf
wrote 1 pdf
One block produced three renderings. The Markdown one is the easiest to read back whole, so start there:
$ cat _md/install.md
# Install
Fetch the archive, unpack it, and put the binary on your path:
`install.sh`
```bash
set -euo pipefail
curl -fsSL https://example.com/tool.tar.gz -o tool.tar.gz
tar -xzf tool.tar.gz
install -m 755 tool/bin/tool /usr/local/bin/tool
```
The filename became a line of its own. The language tag became the fence's info string. Nothing else survived, because nothing else means anything in Markdown. What each backend draws works through the other two.
The fields
Code declares nine fields and no more. There is no width, no theme, and no highlight-these-lines list.
| Field | Type | Written as | What it does |
|---|---|---|---|
| language | identifier | the inline label, code bash { | Picks the highlight grammar and labels the listing |
| source | utf8? | source = <<'SH' … SH | The listing text, verbatim |
| source_file | utf8? | source_file = "install.sh" | Read the listing from this file instead. See Reading a listing from a file |
| anchor | utf8? | anchor = "setup" | Take only the marked region of source_file |
| lines | utf8? | lines = "12-30" | Take only that line range of source_file |
| dedent | bool? | dedent = true | Strip the indentation the selected region shares |
| filename | utf8? | filename = "install.sh" | Names the listing. See Filename and caption |
| id | identifier? | id = "install" | HTML only: the id attribute on the <pre> |
| class | list<utf8>? | class = ["wide"] | HTML only: added beside code-block on the <pre> |
source and source_file are both optional in the schema, and exactly one of them must be set. Neither leaves nothing to render; both would make the build choose a winner, and the wrong guess is a listing that silently disagrees with the file beside it. Either way the build stops and says so.
id and class are HTML-shaped, and they behave that way: the Markdown and PDF backends read the same lowered listing and drop both. Reach for them when a stylesheet or a link needs to address one listing on a page, not when you want the listing to look different everywhere.
Writing the source
source is an ordinary utf8? field, so any string expression fills it. When a listing is written into the document rather than read from a file, it is almost always a raw heredoc, for one reason: a listing is full of the characters a plain string treats as syntax.
# Right: a raw heredoc. Backslashes and ${…} are literal text.
code python {
source = <<'PY'
path = f"{root}\n{name}"
PY
}
# Wrong: an interpolating heredoc. `${root}` is evaluated as WCL.
code python {
source = $<<PY
path = f"{root}"
PY
}
Nothing in source runs through the inline-pattern engine either. A listing containing **stars** or [brackets](x) keeps them exactly as typed. The patterns apply to prose, not to code. See Text and formatting.
Reading a listing from a file
A listing written into the document is a copy of code that lives somewhere else. Nothing keeps the two in step, and nothing notices when they drift apart. A manual with two hundred listings has two hundred places where the page can quietly stop being true.
source_file names the file instead, and the build reads it:
code rust {
source_file = "../src/retry.rs"
}
The path is resolved against the directory of the document that names it — not the entry document, and not the working directory. A relative path may climb out of the doc tree, which is the ordinary case: the code a manual quotes usually lives beside the manual rather than under it. An absolute path is taken as given.
Taking part of a file
A whole file is rarely the listing you want. Narrow it two ways.
anchor takes the region between two marker comments in the quoted file:
use Duration;
// ANCHOR: backoff
from_secs
}
// ANCHOR_END: backoff
code rust {
source_file = "../src/retry.rs"
anchor = "backoff"
}
The match is on the text ANCHOR: name, anywhere on the line, so the marker sits in whatever comment syntax the quoted file uses. // ANCHOR: x, # ANCHOR: x and <!-- ANCHOR: x --> all work, and wdoc never needs to know the language to find them.
lines takes a 1-based inclusive range. Write it four ways: "12-30", "12-" to the end, "-30" from the start, or "12" for a single line.
code rust {
source_file = "../src/retry.rs"
lines = "3-7"
}
Prefer anchors. A line range is addressed by position, so it silently means something different the moment anyone inserts a line above it — which is the drift this feature exists to stop. An anchor moves with the code it marks. Setting both anchor and lines on one block fails the build rather than picking a precedence rule nobody would remember.
Markers never reach the page
An ANCHOR: comment is bookkeeping addressed to wdoc, so it is dropped from every listing — anchored, line-ranged, or whole-file. Anchors may nest, and an inner one's markers vanish from the outer region too. The listing above, which *shows* the markers, is written inline with source for exactly that reason.
Indentation
A region lifted out of a nested scope arrives with the indentation of where it used to live. Anchor a method inside an impl block and every line starts four columns in:
// ANCHOR: inner
attempt < self.max_attempts
}
// ANCHOR_END: inner
}
dedent strips the indentation every non-blank line shares, and leaves the structure inside it alone:
code rust {
source_file = "../src/retry.rs"
anchor = "inner"
dedent = true
}
The four shared columns go; the four inside the method body stay:
attempt < self.max_attempts
}
What names the listing
filename defaults to source_file when the listing comes from disk, so the card header names the file that was actually read. Set filename explicitly to label it something else — the repository-relative path, say, rather than the path the document had to walk to reach it:
code rust {
source_file = "../../crates/retry/src/lib.rs"
filename = "crates/retry/src/lib.rs"
}
When the file is not there
A read that fails fails the build. A missing file, an anchor the file does not mark, a line range past its end: each stops the build with a message naming the file, and exits 3.
$ wcl wdoc build listing.wcl --out _site
wcl::eval::user_error
× error: code block cannot read `source_file` "../src/gone.rs"
│ (/home/me/docs/../src/gone.rs): No such file or directory (os error 2)
╭─[/home/me/docs/listing.wcl:2:14]
1 │ import <wdoc.wcl>
2 │ page index { code rust { source_file = "../src/gone.rs" } }
· ──────────────────────┬─────────────────────
· ╰── error raised here
╰────
$ echo $?
3
The diagnostic points at the block, names the path as you wrote it, and gives the path it resolved to — which is the one to read when a relative path went somewhere you did not expect.
That is the whole point of naming a file rather than copying it. Rendering an empty card, or last build's text, would put back exactly the silent drift the field exists to catch. A listing that names a file is a listing the build is promising to keep true.
wcl check does not catch it
check parses and validates against the schema; it never renders, and the file is read at render time. A source_file pointing at nothing passes wcl check and fails wcl wdoc build. Put the build in CI, not just the check.
wdoc serve does not watch the file
The dev server's watcher tracks .wcl sources only, so editing the quoted code does not add to its pending-changes note. Rebuilds are manual anyway — press Enter — and a full rebuild re-reads every file-backed listing, so the change does land. It just is not what prompts you to ask for it.
The language tag
The tag is the block's inline label, so you write it before the brace and nowhere else:
code rust {
source = "fn main() {}"
}
It does two jobs at once, and they are worth keeping apart. It picks the grammar the highlighter uses. It is also shown verbatim: as the label in the HTML card's header bar, and as the fence info string in Markdown. The two can disagree, and that is not always a mistake: a tag no grammar answers to still labels the listing and still produces a fence a Markdown consumer may know how to colour.
Which tags resolve
wdoc bundles 214 grammars in all: the syntect defaults, two-face's curated extras, and WCL's own. So code wcl { … } highlights the language this book is written in. A tag resolves in two steps:
- Match the tag against every grammar's file-extension list, ignoring case. rs finds Rust. md finds Markdown. yml finds YAML.
- Otherwise match the grammar's name, ignoring case. python finds Python. Rust finds Rust. dockerfile finds Dockerfile.
No match means plain text: the listing renders, escaped and un-coloured. Here are the tags a technical document reaches for most, all checked against the loaded set:
| Grammar | Tags that reach it | Grammar | Tags that reach it |
|---|---|---|---|
| WCL | wcl | JSON | json |
| Rust | rust, rs | YAML | yaml, yml |
| Python | python, py | TOML | toml |
| JavaScript | javascript, js | XML | xml |
| TypeScript | typescript, ts | INI | ini |
| TypeScriptReact | tsx | Markdown | markdown, md |
| Go | go | SQL | sql |
| C | c | Diff | diff |
| C++ | cpp, "c++" | HTML | html |
| C# | "c#", cs | CSS | css |
| Java | java | SCSS | scss |
| Kotlin | kotlin | Dockerfile | dockerfile |
| Ruby | ruby, rb | Makefile | makefile, make |
| PHP | php | CMake | cmake |
| Swift | swift | Terraform | terraform, hcl |
| Haskell | haskell, hs | GraphQL | graphql |
| Scala | scala | Protocol Buffer | proto, protobuf |
| Lua | lua | nginx | nginx |
| Elixir | elixir | LaTeX | tex, latex |
| Nix | nix | reStructuredText | rst |
| Zig | zig | Bourne Again Shell (bash) | bash, sh, zsh |
| Julia | julia | Fish | fish |
| Plain Text | txt | AWK | awk |
The list is not the limit. Anything with a Sublime grammar in the bundled set is reachable the same way: Clojure, Erlang, Dart, Perl, OCaml, F# (as "f#"), R, VimL, Typst, Svelte, Vue, GLSL, WGSL, jsonnet, x86 assembly and about a hundred and fifty more.
Tags that do not resolve
Some obvious-looking tags find nothing, because neither an extension nor a grammar name spells them that way. Each of these renders as plain text:
| Tag | What happens | Write instead |
|---|---|---|
| shell | plain text | sh, bash or zsh for a script, console for a session |
| plaintext | plain text | text or txt |
| csharp | plain text | "c#" or cs |
| fsharp | plain text | "f#" |
| powershell | plain text | nothing. No PowerShell grammar ships |
text is the useful one for output with no prompts in it. No grammar is named text and no file extension is text, so code text { … } falls straight through to plain text. txt reaches the real Plain Text grammar and renders identically.
console for a terminal session
A transcript is not a script. It interleaves what you typed with what the command printed back, so a script grammar is the wrong tool: bash tokenises the output as source, and an apostrophe in a diagnostic such as field 'replicas' is below @min(0) opens a string literal that mis-colours the rest of the line.
console is a session grammar, bundled alongside the WCL one because no shell-session grammar ships with the syntax set. It colours the prompt marker and the command you ran, and leaves every other line untouched:
$ wcl check app.wcl --json
wcl::eval::schema_violation
× field 'replicas': value -1 is below @min(0)
app.wcl: 1 schema violation
The prompt, the program name and its options carry token classes. The four output lines carry none, so they render exactly as text would have. Tag a session console, a shell script bash, and bare output with no $ line text.
A tag with punctuation must be quoted
The tag is an identifier, and c++, c#, and f# are not valid bare identifiers. Quote them, code "c#" { … }, exactly as you would quote any other block label. The quotes are about the text, not the declared type. See Documents, fields and blocks.
A missing tag is not caught
The schema declares language as a required field, but nothing currently refuses a block with no label at all: code { source = "…" } passes wcl check, renders as plain text, and emits an empty language label and a bare class="language-". That is a gap, not a feature. Always write a tag. Write text when there is no language.
Filename and caption
filename is the only caption a listing has. There is no caption field on code. The filename is the caption, and each backend puts it where a caption belongs in that medium.
code python {
filename = "fib.py"
source = <<'PY'
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
PY
}
| Target | Where the filename goes |
|---|---|
| HTML | The card's header bar, as <span class="code-name">, left of the language label |
| Markdown | A backticked line immediately above the fence |
| A monospace caption paragraph immediately above the listing |
Leave it out and each target drops that part: the HTML header bar keeps its window dots and language label, and Markdown and PDF start at the listing. A filename is worth writing whenever the reader is meant to save the listing somewhere. In a document that teaches, that is most of the time.
A listing read from a file names itself. When source_file is set and filename is not, the path becomes the caption — see What names the listing.
code is not a native block
Most of the heavyweight blocks in wdoc are native: a terminal, a tilemap, a tree, a table. wdoc intercepts a native block in Rust because its output is not expressible in WCL: measured widget layout, calendar arithmetic, an ANSI grid. See Writing your own blocks for what that means and what it costs.
code is not one of them. It is an ordinary ContentBlock with an ordinary lower, and the whole of it is this:
@block("code")
type Code extends ContentBlock {
@inline(0) language: identifier
source: utf8?
source_file: utf8?
anchor: utf8?
lines: utf8?
dedent: bool?
filename: utf8?
id: identifier?
class: list<utf8>?
lower = fn(c: Code) -> list<Content> [
Content::Code {
source: c.source,
source_file: c.source_file,
anchor: c.anchor,
lines: c.lines,
dedent: c.dedent,
language: c.language,
filename: if c.filename == none { c.source_file } else { c.filename },
id: c.id,
class: c.class,
},
]
}
Content::Code is a node of the semantic content IR: the closed, target-neutral union every backend matches exhaustively. The lowering carries the fields across and stops. It does not build a card. It does not pick a colour. It does not know which target is running. It does not read the file either — that happens once, later, to the node.
The alternative is worth picturing, because it is the shape this block deliberately does not have. Build the card in the lowering and the header bar becomes HTML markup. A second target that wants a filename then has to reach past the lowering and re-read the block's own fields to get one. One payload and three readings of it is what keeps a field from meaning something on one target and nothing on the next. Output targets covers the union and the backends in full.
Two consequences follow, and both are practical:
- A listing means the same thing everywhere. Add a filename and it appears in all three outputs, in the shape each one uses for a caption. There is no target where the field is quietly ignored.
- Your own block can produce a listing. A @block type extending ContentBlock may return a Content::Code from its lower, and it inherits all three renderings for free. Neither a generated-config block nor a manifest block that prints itself as JSON needs a line of Rust. See Writing your own blocks.
What each backend draws
One Content::Code, three chromes. Read the table down a column to see what one target does with a listing, and across a row to see where a field goes on each of them.
| Aspect | HTML, wdoc build | Markdown, wdoc build --type markdown | PDF, wdoc build --type pdf |
|---|---|---|---|
| Chrome | A <figure class="code-card"> with a header bar and three window dots | None | None |
| Filename | In the header bar | A backticked line above the fence | A monospace caption line |
| Language tag | In the header bar, upper-cased by CSS, and as class="language-<tag>" | The fence info string | Not shown |
| Highlighting | <span class="tok-…"> runs, coloured by the theme's CSS | None. The consumer colours the fence | Coloured runs drawn into the page |
| Line numbers | A CSS counter gutter, always on | None | None |
| id / class | On the <pre> | Dropped | Dropped |
The HTML code-card
The book card is a fixed shape. Build the listing.wcl from the top of this chapter and read _site/install.html, and the listing is exactly this (token spans elided):
install.sh
bash
……
Three things in there are worth knowing about. The <span class="code-lang"> holds the tag exactly as you wrote it, bash and not BASH. The upper-casing is a CSS rule on the class, so a theme may drop it. Every source line is wrapped in its own <span class="code-line">, and the gutter numbers are a CSS counter on that wrapper rather than text in the markup. That is why copying a listing out of the page copies the code and not the numbers. And the gutter is not optional: HTML listings always carry line numbers.
Per-line highlighting restarts the grammar
Wrapping each line separately is what makes the gutter work, and it has a real cost: the HTML highlighter starts each line from scratch. A construct that spans lines loses its scope after the first one. Give it a Rust block comment and line 1 is comment-coloured, while spanning lines */ on line 2 comes back as ordinary source with * and / read as operators. Short listings, the kind a document carries, are unaffected. The PDF backend has no gutter and carries grammar state across lines, so the same listing prints correctly there. When a listing needs a long block comment, prefer line comments.
The Markdown fence
The one piece of real work Markdown does is the fence itself. The backend widens it past the longest backtick run in the source, so a listing that contains a fenced block still nests correctly.
code markdown {
filename = "readme.md"
source = <<'MD'
Fence inside:
```rust
fn main() {}
```
MD
}
The three-backtick run inside forces a four-backtick fence around it:
`readme.md`
````markdown
Fence inside:
```rust
fn main() {}
```
````
Highlighting is not wdoc's job here. The tag rides out on the fence, and whatever renders the Markdown, a repository host or a static site, colours it with its own grammars. That is also why a tag no syntect grammar answers to is still worth writing: code shell { … } is plain text in the built site and a highlighted shell block on a repository host.
The PDF listing
The PDF backend has no CSS to colour a tok- class with, so it does the colouring itself. It highlights the source into runs of text plus an RGB triple, against a light theme chosen to read on a white page, and draws those runs into the page. A filename becomes a monospace caption paragraph above the listing. There is no card, no gutter, and no way to restyle the token colours from the document. A PDF listing looks the same whichever theme the site selects. Output targets covers the rest of the PDF backend.
Colouring the tokens
In HTML, every token is a <span> carrying its syntect scope as a run of classes with a tok- prefix, outermost scope first. A Rust fn keyword comes out as:
fn
The bundled rules target the short, stable ends of those scopes: .tok-keyword, .tok-string, .tok-comment, .tok-constant, .tok-entity.tok-name.tok-function and a few more. Each one resolves a palette variable with a literal fallback, so a document with no theme still gets a readable light palette.
So the ordinary way to recolour code is not to write CSS at all. A theme's palette carries seven syntax roles. Set them and every listing on the site follows:
theme midnight {
palette dark {
syn_kw = "#c678dd"
syn_str = "#98c379"
syn_num = "#d19a66"
syn_fn = "#61afef"
syn_type = "#e5c07b"
syn_comment = "#7f848e"
syn_punct = "#abb2bf"
}
}
site manual {
title = "Manual"
theme = :midnight
}
Some scopes have no bundled rule, and the card chrome has none either. For anything the roles do not reach, declare a class block against the tok- selector or against code-card, code-filename, code-name, code-lang and code-block. Styling rules covers palettes, roles and class blocks in full.
Where to go next
- Text and formatting. Prose, headings, and the backtick inline pattern, which is a different thing from this block.
- Demo blocks. Show a snippet and its rendered result side by side. A demo is a code block plus a live rendering.
- Output targets. The content IR, the three backends, and what each one can and cannot express.
- Themes. The seven syn_* roles a palette declares for code.
- Styling rules. The class block a tok- scope or a card element is restyled through.
- Visibility. @only and @except, for the listing that should reach one target and not another.
- Writing your own blocks. lower, @native, and how a block of yours can produce a listing.
- Values and primitives. Heredocs, raw and interpolating, in full.