Text and formatting
Most of a document is prose. The vocabulary for it is small: six heading blocks, two paragraph blocks, one grid, and a regex engine that turns markers such as **bold** into styled runs. This chapter covers all four. It covers the anchors and section numbers a heading grows on its way to HTML. It covers how to declare a marker of your own, and the one thing a span used to do and no longer does.
Every file and every command below was run before it was written down. Type them and compare.
§ 1One page of prose
Start with a whole document. Save it as notes.wcl:
# notes.wcl — one page of prose and nothing else.
import <wdoc.wcl>
# A pattern of our own: `#177` becomes a link to the tracker.
inline_pattern issue {
pattern = "#([0-9]+)"
boundary = true
to_span = fn(g: list<utf8>) -> list<InlineSpan>
[InlineSpan::Link {
text: $"issue ",
href: $"https://github.com/wiltaylor/wcl/issues/",
class: ["link"],
}]
}
site notes {
default_template = :book
title = "Release notes"
toc { chapter "0.4" { page = release_04 } }
}
page release_04 {
title = "0.4"
h1 "Release 0.4"
p "The **columns** release. `column` lays a page out side by side, and the inline engine grew a `boundary` gate. Both landed in #177."
h2 "Highlights"
column {
widths = [50.0, 50.0]
text {
span "Two blocks, "
span "one paragraph — "
span "three spans."
}
p "The second slot. Whatever you write here sits _beside_ the paragraph on its left."
}
h2 "Upgrading"
p <<'TXT'
Nothing to do. The `widths` list is the only new required field, and
only a `column` asks for it. See [the highlights](release_04#highlights).
TXT
}
Build it:
$ wcl wdoc build notes.wcl --out _site
wrote 1 page
This is the page body _site/release_04.html carries, reformatted onto one line per block:
Release 0.4
The columns release. column lays a page
out side by side, and the inline engine grew a boundary gate. Both
landed in issue 177.
§ 1Highlights
Two blocks, one paragraph — three
spans.The second slot. Whatever you write here sits beside
the paragraph on its left.
§ 2Upgrading
Nothing to do. The widths list is the only new required field, and
only a column asks for it. See
the highlights.
Six facts are already visible in that output, and the rest of the chapter is those six in depth. A heading became a real <h2> with an id nobody typed and a § 1 marker nobody asked for. **columns** became a <span class="bold">. The three span children became one paragraph with no wrapper of their own. The column became a CSS grid. The bare #177 went through a pattern this document declared. And [the highlights](release_04#highlights) resolved to a real file name, because release_04 is a page in this site.
§ 2Headings
Six blocks, h1 through h6. Each takes the heading text as its inline label and an optional id. There are no other fields.
h1 "Release 0.4"
h2 "Highlights"
h3 "Columns" { id = "columns-section" }
h4 "Widths"
h5 "Rounding"
h6 "Trivia"
The level is a number carried on the lowered node, not a class the backend reads back. The HTML backend then emits both: a real <h3> tag, plus a heading-3 class as a style hook derived from that number. The bundled theme sizes each level through class "heading-3" { … }. Declare your own class of that name and it overrides the bundled one by cascade. See Themes and styling.
Inline patterns run in a heading like anywhere else, so h2 "Fixing **fast**" renders the emphasis. The page's own title field is not prose — it is metadata for the <title> tag and the navigation, and markers in it stay literal.
§ 2.1Anchors
Every heading gets an id, whether or not you write one. A heading with no anchor cannot be linked to. Write one explicitly in the id field and it is used verbatim. Note the quotes in the snippet above: the field's type is identifier, and a bare identifier holds no hyphen. Quote an id to write it in the same hyphenated style the derived ones use.
Leave the field out and the id is derived from the rendered heading text by four rules:
- ASCII letters and digits are kept, lowercased.
- Every later run of other characters becomes a single -. A leading run is dropped, not converted.
- A trailing - is trimmed. Text with no letters or digits at all becomes section.
- A title used twice on one page gets -2, -3, … appended to the second and later copies.
So h1 "Release 0.4" anchors at release-0-4, and a second h2 "Highlights" anchors at highlights-2. Markers are stripped before the slug is cut, because the id comes from the rendered text. h2 "Fixing **fast**" is therefore fixing-fast, not fixing-fast-.
That is what the page#anchor link form points at. Both halves are written without a file extension:
p "See [the highlights](release_04#highlights) and [chapter 23](wdoc_code)."
The page name is checked; the fragment is not
A link whose target names no page in the site fails the build — wcl wdoc build prints link to unknown page 'nowhere' and exits non-zero. That check stops at the #. [x](wdoc_code#no-such-heading) builds green and lands the reader at the top of the right page. When you link to an anchor, open the target chapter and read the heading you are pointing at.
§ 2.2Section numbers and the page outline
The § 1 in the built HTML is not in the source. A page-wide finishing pass walks the emitted headings in reading order, stamps each with its id, and prefixes h2 and h3 with a <span class="heading-marker">. h2 counts 1, 2, 3; h3 counts 1.1, 1.2 inside the current h2, and resets on the next one.
Only h2 and h3 are numbered, and only they appear in the book template's "on this page" outline. That is not a styling accident: h1 is the page's own title and the sidebar already names it, and h4 through h6 are detail below the granularity a reader navigates by. They still render, still get anchors, and still take links — they are simply not part of the outline. Structure a chapter with h2 and h3; reach for h4 when you genuinely need a fourth level, knowing it will not show up in the nav.
Anchors and numbers are an HTML finish
The Markdown backend writes ## Highlights — no id, no §, because Markdown has neither. The PDF backend renders a heading as one styled run, which has a sharper consequence: inline markers inside a heading are not interpreted there, so h2 "Fixing **fast**" prints its asterisks in the PDF while rendering bold in HTML. Keep markers out of headings in a document you also ship as PDF. See Output targets.
§ 3Paragraphs
p "…" is the paragraph. Its label is the prose; the whole string runs through the inline-pattern engine. Two optional fields ride along, id and class, and both land on the emitted <p>:
p "Plain prose with **bold** and `code`."
p "A paragraph you can link to and style." {
id = summary
class = ["lede"]
}
Plain prose with bold and code.
A paragraph you can link to and style.
§ 3.1text and span
text is the long form of the same paragraph. It takes an optional label of its own, plus any number of span children, and it takes the same id and class fields:
text "Label run. " {
id = intro
class = ["lede"]
span "First span. "
span "Second span."
}
Label run. First span. Second span.
Read that output carefully, because it is the whole behaviour. The label and the spans concatenate into one paragraph, in source order, with nothing between them. The trailing spaces in the source above are what separates the sentences. There is no wrapper element per span. And the paragraph carries the id and class of the text, not of any span.
A span's own id and class no longer render
span "…" { class = ["accent"] } used to paint, and does not any more. Blocks lower to a semantic content IR, and its prose node carries a string, not a list of styled runs. There is nowhere for a per-span id or class to go, so the fields are dropped on the way through. Outside HTML this changed nothing. The Markdown and PDF walkers already flattened a text to its concatenated child text. In a book, though, a styled span stopped painting. The fields stay declared, so existing documents keep validating rather than failing on a field that no longer does anything. Carrying them back needs an inline-run concept in the IR, which is its own piece of work. Until then, style the whole paragraph with class on the text, and style within it with the inline patterns below. **bold** and friends emit their own <span> and are unaffected.
Which leaves text doing one job that p cannot: composing a paragraph out of separately-computed pieces. Each span label is an expression, so a repeater, a match, or a call can produce one — see Data views.
§ 3.2Which paragraph form to use
Three ways to write one paragraph, and they are not interchangeable:
| Form | The prose is | Reach for it when |
|---|---|---|
| p "…" | One string label | Always, unless one of the rows below applies |
| p { text = <<'TXT' } | A heredoc, verbatim | The prose is long, or full of quotes and backslashes |
| text { span … } | A label plus spans, concatenated | The pieces are computed separately |
All three lower to the same node and render to the same <p>. Nothing downstream can tell them apart.
§ 4Heredoc prose
A paragraph label is an expression, so anywhere p "…" is legal a heredoc is too. Both spellings work — the label slot and the text field:
p <<'TXT'
Nothing to do. The `widths` list is the only new required field, and
only a `column` asks for it.
TXT
p {
id = upgrade
text = <<'TXT'
The same prose, written as a field so the block can carry an `id`.
TXT
}
Reach for a heredoc when the prose fights the quoting rules. Inside "…" every double quote needs a backslash, and every backslash needs another one. In a <<'TAG' heredoc the body is taken verbatim: no escapes, no interpolation. A sentence full of quoted names, Windows paths or a regex you are documenting then reads as itself. The $<<'TAG' form is the interpolating twin, if you want ${…} slots. Values and primitives has the full heredoc syntax.
Two things to know. The body keeps its trailing newline. That is harmless in HTML, where a newline is whitespace. It is also why a heredoc paragraph's closing tag lands on its own line in the built file. And the line breaks you type are not paragraph breaks: the whole heredoc is one <p>, wrapped by the browser. Two paragraphs means two p blocks.
§ 5Inline patterns
The markers are not hard-coded. Each one is an inline_pattern block declaring a regex and a function from the captured groups to a list of InlineSpan values. The built-ins ship in the wdoc standard library, and a document can add its own or override one. These are the built-ins, written here inside a code block so they stay literal:
**bold** → <span class="bold">
_italic_ → <span class="italic">
`code` → <span class="code">, contents verbatim
[text](page) → a link to a page in this site
[text](site:page) → a link into another site of the same build
[text](https://…) → an external link
https://… → the same, with no brackets
:lucide.check: → an inline icon
$x^2$ → a text-style equation
$$\int x \, dx$$ → a display-style equation
The first three are this chapter's; Links below covers the four link forms; Icons and Math cover the last three.
The three emphasis markers produce nothing but a <span> with a class. The class is where the styling lives: the bundled class "bold", class "italic" and class "code" carry the weight, slant and monospace family, and the selected theme adds the colour. Declare a class of the same name to override either — Themes and styling covers it.
§ 5.1Where the engine runs
Every prose string a block declares goes through the engine. In practice that is:
- a p label, a text label, and every span inside a text;
- h1 through h6;
- an li in a list;
- a table's header cells and its utf8 body cells;
- a callout's heading and its body;
- the prose inside a card, a node_table row, and the other blocks that hold body text.
It does not run on a code block's source, which is the point of a code block. Nor on a page's title, a filename, or any other field that is metadata rather than prose.
§ 5.2Matching rules
Five rules decide what a string becomes. They are worth knowing in order, because most surprises are one of them:
- Earliest start wins. Every pattern is tried at each position; the match that begins first is taken, and scanning resumes after it. Wrap **not bold** in backticks and the asterisks survive, because the backtick comes first.
- A tie goes to declaration order, and the document's own blocks are enumerated before the imported library's. That is how a pattern of yours overrides a built-in with the same syntax.
- A code span is verbatim. Its contents are not re-scanned, on any backend, which is why safe_mode_password keeps its underscores in this sentence.
- Everything else nests. The text a pattern returns is fed back through the engine, up to eight levels deep, so **bold with _italic_ inside** works.
- There is no escape character. A backslash is an ordinary character to the engine. To show a marker literally, put it in a code span — that is what the rest of this chapter does.
§ 5.3Links
Four link forms, one pattern. The bracket form takes any of three targets, and a bare URL needs no brackets at all:
p "In this site: [chapter 23](wdoc_code), or [an anchor](wdoc_code#code-blocks)."
p "In another site of the same build: [the guide](handbook:getting_started)."
p "Outside: [example](https://example.com), or bare https://example.com/docs."
A bare token target is a page name, not a path. The build rewrites it to wdoc_code.html for the web target and wdoc_code.md for Markdown, so one source works for both. A target that starts with #, /, ./ or ../, that contains ://, or that carries a mailto: / tel: scheme passes through untouched and is never checked. A site:page target crosses to another site declared in the same document and gets however many ../ steps that takes; Documents, pages and sites covers multi-site builds.
The bare-URL form drops the scheme from the link text: https://example.com/docs renders as example.com/docs. It also stops before sentence-final punctuation, so a URL at the end of a sentence keeps its full stop as prose.
An unresolved target fails the build:
$ wcl wdoc build notes.wcl --out _site
link to unknown page 'nowhere'
$ echo $?
2
§ 5.4Footnote references
[^id] in prose is a footnote reference. It is not an inline pattern. The same page-wide finishing pass that stamps heading anchors resolves it, after the whole page is rendered. That is what lets a reference stand before the footnotes block that defines it:
p "A claim that needs a source[^src], and another[^two]."
footnotes {
footnote "src" { text = "The first note." }
footnote "two" { text = "The second note." }
}
A claim that needs a source1,
and another2.
The marker inside the brackets is the footnote's declaration id. The printed number comes from the order the definitions appear in, not from the order the references do. A [^id] naming no defined footnote is left in the prose exactly as typed, which is deliberate: it costs nothing when a [^…] is genuinely part of the text. Callouts, footnotes and chapter headers covers the footnotes block itself.
§ 5.5Declaring your own pattern
An inline_pattern block is a name, a regex, an optional boundary gate, and a to_span function. It must sit at the document root — beside the site, not inside a page — and it then applies to every page the build renders.
inline_pattern strike {
pattern = "~~([^~\n]+)~~"
to_span = fn(g: list<utf8>) -> list<InlineSpan>
[InlineSpan::Plain { text: at(g, 1), class: ["strike"] }]
}
class "strike" { css = "text-decoration:line-through;" }
to_span receives the regex's capture groups as a list<utf8>: at(g, 0) is the whole match, at(g, 1) the first explicit group, and so on. It returns a list, so one match may produce several spans. Four variants are available: Plain (a classed <span>), Link, Icon and Math. A to_span that returns something other than a list leaves the matched text alone rather than failing the build.
boundary = true skips a match that touches a letter, digit or underscore on either side. The built-in italic uses it, which is why safe_mode_password keeps its underscores while a _word_ here formats. Turn it on for any marker built from characters that also occur inside identifiers.
A pattern that re-emits its own trigger nests eight deep
Because a returned span's text is fed back through the engine, a pattern whose output contains the syntax that produced it matches itself. Return at(g, 0) from the #([0-9]+) pattern above and #177 renders as eight nested <a> tags. The depth cap is the only thing that stops it, and the page is silently wrong rather than broken. Return text the pattern cannot match: at(g, 1) (just the digits), or a rewrite like $"issue ${at(g, 1)}". The built-in bare-URL pattern does exactly this — it drops the https:// from its own display text.
§ 6Columns
column is the one block in this chapter that is about layout rather than prose. It puts its children side by side instead of stacked. widths gives one CSS percentage per slot, and the children fill the slots in source order:
column {
widths = [50.0, 50.0]
p "**Left.** The first child fills the first slot."
p "**Right.** The second fills the other half."
}
Left. The
first child fills the first slot.Right. The second fills the
other half.
It is a CSS grid, and that explains the edges. Any number of slots is fine — [33.3, 33.3, 33.3] for equal thirds. A child count that is not a multiple of the slot count simply wraps onto another row, because that is what a grid does. id and class on the column land on the wrapping <div>.
One child is one slot, whatever it is, and there is no bare wrapper block to group two of them. Nest a single-slot column when a slot needs more than one block:
column {
widths = [50.0, 50.0]
column {
widths = [100.0]
h3 "Left"
p "Two blocks, one slot."
}
p "Right."
}
Children may be any wdoc content — paragraphs, headings, callouts, code, diagrams, another column.
The layout is HTML-only
column is a native block on every target, and only the HTML backend draws the grid. Markdown and PDF render the children stacked in source order: the layout is lost, the content is not. Do not use a column to carry meaning that the reading order does not. A left-hand column that only makes sense beside its right-hand neighbour becomes two consecutive paragraphs in the PDF.
§ 7Where to go next
- Code — code blocks, the language tags, and why their source is the one prose the engine leaves alone.
- Callouts, footnotes and chapter headers — the footnotes block behind [^id], and the admonitions this chapter kept using.
- Lists and tables — list / li and table, both of which run every cell through the inline engine.
- Themes and styling — the class block behind bold, italic, code and heading-N.
- Icons and Math — the two built-in patterns this chapter only named.
- Output targets — what the HTML, Markdown and PDF backends each make of the blocks above.
- Writing your own blocks — when a pattern is not enough and you want a block of your own.