wdoc · reference

Output targets

9 min read · 2026-08-21 · wcl 0.33.2-alpha

One document, three outputs, one command. wcl wdoc build --type html renders a website, --type pdf a paginated PDF, and --type markdown a folder of .md files (md is an alias). html is the default, so a bare wcl wdoc build is the website. wcl wdoc serve is that first target with a watcher and a browser attached, not a fourth thing.

This chapter covers each target: what it writes, what it can carry, and what it cannot. It then covers the mechanism that keeps the three honest: one closed content vocabulary that every backend must match exhaustively. Two dev-loop devices build on that mechanism, and they are here too: wcl wdoc serve and the markdown_source block.

The CLI lists every flag. This chapter is about what happens after you type them.

One document, three renderers

The three targets are not three exporters bolted onto an HTML generator. Each one reads the same evaluated document and the same content vocabulary. Each then decides for itself what a heading, a code listing or a callout looks like.

A block reaches a backend by one of two routes. Most blocks lower. A WCL function on the block's type returns a node of the content vocabulary: a Heading, a Paragraph, a Code, a Callout. Each of the three renderers reads that node. A minority are native: wdoc renders them in Rust, because their output is not expressible in WCL. Writing your own blocks covers both routes.

The consequence for this chapter is the useful one. A block that lowers reaches all three targets by construction. The only blocks that can be missing from a target are the natives that say so. Visibility covers what the build does about that.

A new content node is a compile error in three places

The content vocabulary is a closed union with no generic container and no raw-markup escape hatch. Every backend matches it exhaustively: no catch-all arm in the HTML walker, the PDF walker or the Markdown walker. So a variant added to the union does not silently render as nothing in two of three outputs. It fails to compile until all three say what to do with it. That is the mechanism, not a convention someone remembers to follow.

--type html: the website

build renders every page to HTML and writes the shared assets beside them.

console
$ wcl wdoc build main.wcl --out _site
wrote 2 pages
text
_site/
  index.html          # a copy of the site's start page
  intro.html
  ref.html
  _wdoc/              # fonts, favicon, icon sprite, page manifest, player scripts

_wdoc/ holds everything the pages share, and every page references it by a plain relative path. That is what makes an output tree relocatable: move _site/ anywhere, serve it from any prefix, and the links still resolve. Documents, pages and sites covers the multi-site layouts and what --site does to them.

build never wipes --out

The build creates the output directory if it is missing, then writes into it. It never empties the directory first, so a build can write beside hand-authored files. A renamed or deleted page leaves its old file behind.

When the output has to match the document exactly, delete the directory yourself. Documents, pages and sites works that consequence through with a rename.

Everything the site needs is served, not read from disk

Search fetches its index over HTTP, and so do the player scripts. The site works when a host serves it, or when wcl wdoc serve does. Opening a page directly from disk gives you the prose and loses the search box.

wcl wdoc serve: the dev loop

serve runs a build, then serves the result over HTTP with a watcher and a live-reload script in every page.

console
$ wcl wdoc serve main.wcl --out _serve
rendered 2 pages
serving http://127.0.0.1:8199  (source: main.wcl, out: _serve)
auto-rebuild is off — press Enter here to rebuild after edits

That third line is the design, not a missing feature. The watcher sees every .wcl change and accumulates it. It does not rebuild:

text
2 file changes pending — press Enter to rebuild

A rebuild happens when you ask for one, and there are exactly two ways to ask. Press Enter in the console where serve is running, or send POST /__wdoc_rebuild:

console
$ curl -s -X POST http://127.0.0.1:8199/__wdoc_rebuild
{"ok":true,"summary":"2 pages (intro, ref)"}

The HTTP form waits for the build and reports what it did. An editor hook, or a file-watcher of your own, can therefore trigger a rebuild and know whether it succeeded. The browser reloads on its own: each page long-polls /__wdoc_reload and reloads when the build generation changes. The generation moves on a failed build too, so a browser parked on the error page picks up the fix.

Omit --out while you are writing and you get a temporary directory that serve removes on shutdown.

What a rebuild re-renders

A rebuild drains every pending change at once and hands the whole set to the incremental builder. There is no scoping below that: it is the changed set, or a full build.

The incremental builder maps each changed file onto the top-level blocks that came from it. Say every changed file contributed only page blocks. It then re-renders those pages in place and leaves the shared site-wide artifacts untouched, among them the icon sprite, the search index and the CSS embedded in each page:

text
rebuilt: 2 pages (intro, ref)

Anything else falls back to a full rebuild, and says so:

text
rebuilt: 2 pages (full)

The fallback is deliberately eager, because the failure mode of being clever here is a stale page that looks right. Seven kinds of change force a full build: an imported library, the page set, the CSS, an asset declaration, a repeater, one that pulls in an icon the sprite does not have yet, and a file holding a site block. That is why editing your entry document usually rebuilds everything.

Scoping is by file, not by block

Two pages in one pages.wcl are one unit: edit either and both re-render. That is not a limitation worth working around by splitting files. The saving is in skipping the *other* fifty pages and the aggregate writes, and the parse happens either way because imports force it.

--type pdf: print

--type pdf renders each site to one paginated PDF. It is pure Rust: no browser, no headless Chrome, no external binary.

console
$ wcl wdoc build main.wcl --out _pdf --type pdf
wrote 1 pdf
$ ls _pdf
handbook.pdf

The build names the file after the site, not after the source document. With no site block at all, the source file stem names it. --page-size letter switches from A4. That flag applies to this type only, and passing it with --type html or --type markdown is an error rather than being quietly ignored. The count differs too: pdf reports sites, not pages, so a three-page two-site document says wrote 2 pdfs.

Prose, headings, lists, tables, code listings, callouts, footnotes, chapter headers, images and maths all paginate. The PDF renderer paints diagrams, sequence and state diagrams, terminals and wireframes from the same shape vocabulary the other targets draw. A diagram in a PDF is therefore the same drawing at the same viewBox. It is not a screenshot and it is not a fallback.

What a PDF cannot do is anything interactive or anything that lives beside the document:

--type markdown: text

--type markdown writes one .md per page, plus the standalone .svg files that Markdown references. --type md is an alias.

console
$ wcl wdoc build main.wcl --out _md --type markdown
wrote 1 page
$ find _md -type f
_md/index.md
_md/one.md
_md/_wdoc/one-diagram-1.svg
_md/_wdoc/video-clip-b97542c2.mp4

The target aims at readers that want text: a model, a search index, a repository someone browses on a code host. So it keeps meaning and drops chrome.

Here is a page carrying frontmatter, prose, an equation, a diagram and a video, and the whole file it produced:

_md/one.mdmarkdown
---
author: platform
status: draft
---

# One

Prose with **bold**, `code` and a [link]one.md.

$$
E = mc^2
$$

![diagram]_wdoc/one-diagram-1.svg

[_wdoc/video-clip-b97542c2.mp4](_wdoc/video-clip-b97542c2.mp4)

Read the five conversions out of that:

In the documentIn the Markdown
a frontmatter blocka YAML header. The other two targets pass over it
[link](one)[link](one.md). A page name becomes a file name
a math block$$ … $$, still LaTeX, for the reader to typeset
a diagrama standalone .svg under _wdoc/, referenced by an image
a videoa link. The build copies a local source out first

Two more worth knowing. A code block becomes a fence under its filename rather than a styled card. A callout becomes a GitHub alert (> [!NOTE]), keyed on the callout's kind:

markdown
> [!NOTE]
> <!-- wdoc-callout: note -->
> **One source**
>
> Every target reads the same document.

The comment on the second line is there because GitHub has five alert keywords and wdoc has six kinds. All five keywords are used, tip and success share TIP, and the marker carries the authored kind through regardless — GitHub renders it as nothing, and anything reading the Markdown as data can recover all six. Callouts has the mapping.

Diagrams, terminals and wireframes render as plain SVG rather than as an approximation in text, so a zoomable diagram survives the trip. Equations stay LaTeX for the same reason: converting either to ASCII would lose the thing worth keeping.

markdown_source: previewing the Markdown

There is a block for reading a page's generated Markdown inside the book, next to the rendered version. Wrap the content in markdown_source:

wcl
markdown_source {
  id = "pv"
  h2 "A section"
  p "Prose with **bold** and `code`."
  list {
    li "one"
    li "two"
  }
}

The HTML build lowers the body through the Markdown emitter and shows the result as a highlighted code markdown listing:

markdown
## A section

Prose with **bold** and `code`.

- one
- two

Set id to the previewed page's name. It is the filename stem for any diagram or terminal SVGs the body's Markdown writes. The ![](…) references then line up with what a real wcl wdoc build --type markdown run would produce.

The block is native on :html and only :html, because it taps the Markdown emitter from inside the HTML build. The build refuses it on another target rather than rendering nothing. See Visibility for the waiver.

Choosing a target

The three are not a quality ladder. Each answers a different question.

--typeReach for it whenGive up
htmlPeople will read it in a browsernothing. This is the full-fidelity target, and the default
pdfIt has to print, or travel as one fileinteractivity, file assets, playable video
markdownA model, a diff or a code host will read itlayout, theming, search. It keeps the meaning

Building more than one from the same source is the normal case, and it is what the backends axis is for: the paragraph that makes sense only in a browser carries @only(backends = [:html]), and you write everything else once.

Where to go next