wdoc · reference

Text and formatting

15 min read · 2026-08-20 · wcl 0.33.2-alpha

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, along with the anchors and section numbers a heading gains on its way to HTML. It also 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.

One page of prose

Start with a whole document. Save it as notes.wcl:

notes.wclwcl
# 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 ${at(g, 1)}",
      href:  $"https://github.com/wiltaylor/wcl/issues/${at(g, 1)}",
      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:

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

_site/release_04.html (excerpt)html
<h1 class="heading-1" id="release-0-4">Release 0.4</h1>
<p>The <span class="bold">columns</span> release. <span class="code">column</span> lays a page
out side by side, and the inline engine grew a <span class="code">boundary</span> gate. Both
landed in <a class="link" href="https://github.com/wiltaylor/wcl/issues/177">issue 177</a>.</p>
<h2 class="heading-2" id="highlights">Highlights</h2>
<div style="display:grid;grid-template-columns:50% 50%;"><p>Two blocks, one paragraph — three
spans.</p><p>The second slot. Whatever you write here sits <span class="italic">beside</span>
the paragraph on its left.</p></div>
<h2 class="heading-2" id="upgrading">Upgrading</h2>
<p>Nothing to do. The <span class="code">widths</span> list is the only new required field, and
only a <span class="code">column</span> asks for it. See
<a class="link" href="release_04.html#highlights">the highlights</a>.
</p>

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

Headings

Six blocks, h1 through h6. Each takes the heading text as its inline label and an optional id. There are no other fields.

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

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.

Anchors

Every heading gets an id, whether or not you write one. You cannot link to a heading that has no anchor. Write one explicitly in the id field and the build uses it verbatim. The snippet above quotes each id for a reason: 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 build derives the id from the rendered heading text by four rules:

So h1 "Release 0.4" anchors at release-0-4, and a second h2 "Highlights" anchors at highlights-2. The build strips markers before it cuts the slug, 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. Write both halves without a file extension:

wcl
p "See [the highlights](release_04#highlights) and [the code chapter](wdoc_code)."

The build checks the page name, not the fragment

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 without error 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.

Anchor ids and the page outline

The id on that <h2> is not in the source. A page-wide finishing pass walks the emitted headings in reading order and stamps each with its id. The id is slugged from the heading text, and suffixed -2, -3 when the same text repeats on a page. It injects nothing visible: a heading renders exactly the text you wrote.

The same walk numbers h2 and h3. h2 counts 1, 2, 3. h3 counts 1.1, 1.2 inside the current h2, and resets on the next one. But that number reaches only the page-metadata side, where it decides which levels enter the outline and fills number on each entry. No template in the stdlib prints it. See Templates.

Only h2 and h3 are numbered, and only they appear in the book template's "on this page" outline. The omission 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 not part of the outline. Structure a chapter with h2 and h3. Reach for h4 when you need a fourth level, and know that it will not show up in the nav.

Anchors are an HTML finish

The Markdown backend writes ## Highlights with no id, because Markdown has none. The PDF backend renders a heading as one styled run. The consequence is sharper: it does not interpret inline markers inside a heading, 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.

Paragraphs

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

wcl
p "Plain prose with **bold** and `code`."

p "A paragraph you can link to and style." {
  id    = summary
  class = ["lede"]
}
html
<p>Plain prose with <span class="bold">bold</span> and <span class="code">code</span>.</p>
<p class="lede" id="summary">A paragraph you can link to and style.</p>

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

wcl
text "Label run. " {
  id    = intro
  class = ["lede"]
  span "First span. "
  span "Second span."
}
html
<p class="lede" id="intro">Label run. First span. Second span.</p>

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 separate 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 lowering drops them. Outside HTML, the change made no difference. 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, a separate 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.

So text does 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.

Which paragraph form to use

Three ways to write one paragraph, and they are not interchangeable:

FormThe prose isReach for it when
p "…"One string labelAlways, unless one of the rows below applies
p { text = <<'TXT' }A heredoc, verbatimThe prose is long, or full of quotes and backslashes
text { span … }A label plus spans, concatenatedThe pieces are computed separately

All three lower to the same node and render to the same <p>. Nothing downstream can tell them apart.

Heredoc prose

A paragraph label is an expression, so anywhere p "…" is legal a heredoc is too. Both the label slot and the text field work:

wcl
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, tag unquoted, 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.

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

console
**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. Styling rules covers it.

Where the engine runs

Every prose string a block declares goes through the engine. In practice that is:

It does not run on a code block's source. That exemption is what a code block is for. Nor does it run on a page's title, a filename, or any other field that is metadata rather than prose.

Matching rules

Five rules decide what a string becomes. They are worth knowing in order, because most surprises are one of them:

Four link forms, one pattern. The bracket form takes any of three targets, and a bare URL needs no brackets at all:

wcl
p "In this site: [the code chapter](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: or tel: scheme passes through untouched, and the build never checks it. 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:

console
$ wcl wdoc build notes.wcl --out _site
link to unknown page 'nowhere'
$ echo $?
2

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

wcl
p "A claim that needs a source[^src], and another[^two]."

footnotes {
  footnote "src" { text = "The first note." }
  footnote "two" { text = "The second note." }
}
html
<p>A claim that needs a source<sup class="footnote-ref" id="fnref-src"><a href="#fn-src">1</a></sup>,
and another<sup class="footnote-ref" id="fnref-two"><a href="#fn-two">2</a></sup>.</p>

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. The build leaves a [^id] naming no defined footnote in the prose exactly as typed. That is deliberate: it costs nothing when a [^…] is genuinely part of the text. Callouts, footnotes and chapter headers covers the footnotes block itself.

Declaring 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 and not inside a page. It then applies to every page the build renders.

wcl
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) is the first explicit group, and so on. It returns a list, so one match may produce several spans. There are four variants: 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, so 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 works this way. It drops the https:// from its own display text.

Columns

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:

wcl
column {
  widths = [50.0, 50.0]
  p "**Left.** The first child fills the first slot."
  p "**Right.** The second fills the other half."
}
html
<div style="display:grid;grid-template-columns:50% 50%;"><p><span class="bold">Left.</span> The
first child fills the first slot.</p><p><span class="bold">Right.</span> The second fills the
other half.</p></div>

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

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

Where to go next