Configuration: the doc/wodoc file
The turn-key wodoc build command is driven by one declarative file per project, conventionally doc/wodoc. It is a dune-style S-expression, read by Wodoc.Config, and it replaces a project's old build.sh and its hand-written navigation — there is no menu.wiki, nav.html, leftnav.html or template.html any more: the left navigation is declared here, in the (nav …) stanza, and the page template is generated by Wodoc.Build.
A minimal config (wodoc's own — this very site):
(project wodoc)
(title Wodoc)
(url-prefix /wodoc)
(packages wodoc)
(landing wodoc/index.html)
(nav
(section "Manual"
(link "Overview" wodoc/index.html index)
(link "Commands" wodoc/commands.html commands))
(api-section "API"
(link "API overview" wodoc/api.html api)))Build it with:
wodoc build --config doc/wodoc --out <site>/<label> \
--menu https://ocsigen.org/doc/menu.html --label <label> [--latest] [--local]Core stanzas
Stanza | Meaning |
|---|---|
| required — package / project id, e.g. |
| sub-project label shown by the logo (default: the project id) |
| absolute publish base for the version selector, e.g. |
| which project to highlight in the shared top menu (default: the project id) |
| the odoc output subtrees to assemble, in order; empty = every subtree odoc produced |
| where the version-root |
Navigation: the (nav …) stanza
The left-column navigation is described declaratively here — this is the only way to define navigation; the old wikicréole menu.wiki is gone. (nav …) holds an ordered list of sections:
(nav
(section "Manual"
(link "Overview" index.html index)
(link "Tutorial" tutorial.html tutorial))
(api-section "API"
(link "Modules" pkg/index.html pkg)))(section <heading> <item> …)— a manual block, rendered as<nav class="api-nav manual-nav">. Several sections stack in order, each with its own<h3>heading.(api-section <heading> <item> …)— an API block (<nav class="api-nav">); use it for hand-curated API links. (A client/server project's per-module API nav is generated automatically instead — see below.)(link <label> <path> [<current>])— one entry.<path>is taken relative to the version root (the per-pagebaseis prefixed automatically), unless it is absolute (/talks/…) or a URL (https://…), in which case it is emitted verbatim.<current>is an optionaldata-wodoc-pageid: the entry is highlighted on the page whose id matches (an API subtree's top directory, or a manual page's own basename). Omit it for a link that never needs highlighting.(group <heading> <item> …)— a sub-heading (<h4>) and its nested items, indented one level deeper. Groups nest, so a section can reproduce a multi-level menu (section → subsection → page links).
To point at an in-page anchor (a one-page manual), put the fragment in the path: (link "Section 2" index.html#sec2 index).
A nested example (one <h3> section, an <h4> subsection, indented links):
(section "Server-side programming"
(group "Services"
(link "Service parameters" server-params.html server-params)
(link "Service handlers" server-outputs.html server-outputs))
(link "Sessions" server-state.html server-state))Per-version navigation — --nav
The (nav …) stanza lives in the one doc/wodoc, so it is the same for every version a project builds. A project whose versions have different manuals (e.g. a manual-only project that builds several version directories from the same checkout) instead passes the nav per build, with the same syntax in a separate file:
wodoc build --config doc/wodoc --out <site>/<v> --label <v> \
--mld-dir manual/<v> --nav manual/<v>/nav --menu <…>--nav <file> reads a (nav …) stanza from <file> and uses it instead of the config's. (This replaces the older per-version menu.wiki.)
Cross-reference resolution
odoc leaves some references dead in the HTML; these stanzas rewrite them (see resolve-refs and Wodoc.Resolve).
(sibling <Module> <seg/seg/…>) …— cross-package references to a sibling library built in the same tree; maps a top module to its odoc directory path.(hosted (<pkg> <dir> <layout> <Wrapper>) …)— cross-project references to another hosted Ocsigen project. odoc points these atocaml.org; wodoc rewrites them to relative links into the sibling project's wodoc docs.<layout>is how that project is deployed:multilib(one<pkg>.<lib>/subtree per library, e.g. eliom),root(a single package at the version root) orsubdir(a<pkg>/subtree per package, e.g. js_of_ocaml, tyxml);true/falseare accepted as legacy aliases formultilib/root.<Wrapper>is its wrapper module (or"").
Build inputs and assets
Stanza | Meaning |
|---|---|
| build the API with |
| dune build profile (e.g. |
| also build the |
| package dir that receives |
| a project-specific syntax-highlight starter to ship as |
| turn off the Markdown twins and |
| the page stylesheets. A relative href is copied into the build and linked per-version (self-contained, works at any path); an absolute ( |
Layout variants
Manual at the version root — (manual-root)
A single-package dune build @doc project puts both its manual and its API under a <package>/ subdirectory. (manual-root) strips that leading segment from output paths, the (landing …) and the (nav …) links, so the package deploys at the version root (ocsigen.org/<project>/<v>/page.html) — matching the layout of odoc-driver projects (eliom) so cross-project links resolve.
Manual-only / archived projects — (mld-dir …)
A project with no buildable library API (an archived project, or a tool whose libraries are internal) has nothing for dune build @doc to render. Point (mld-dir <dir> [<package>]) at a directory of .mld pages and wodoc compiles them straight with odoc (preprocess → compile → link → html-generate, no dune); the optional second atom is the odoc --package. (When the directory is passed on the CLI with --mld-dir — for a per-version manual — set the package with a standalone (mld-package <pkg>) instead.) The pages are the manual, so the landing index.html is a real page (no redirect). Add (flat) when the first content straddles odoc's preamble boundary. (static-copy <src> [<dest>]) (repeatable) copies a tree in verbatim — e.g. a frozen API snapshot that can no longer be recompiled, or a manual image.
Client/server projects — (client-server …)
A client/server project (eliom, ocsigen-toolkit, ocsigen-start) builds its API as two or three libraries of one package that share module names (<pkg>.server / <pkg>.client / <pkg>.ppx). Declaring (client-server …) implies (odoc-driver <project>) (these shared names would collide under dune build @doc), so you need not repeat it. The block lists one side per library:
(client-server
(server (lib eliom.server) (indexdoc doc/server.indexdoc)
(heading "Server API") (wrapper Eliom) (skip "Index"))
(client (lib eliom.client) (indexdoc doc/client.indexdoc)
(heading "Client API") (wrapper Eliom)))Each side gives its (lib <pkg>.<side>), a curated (indexdoc <file>) (a section-grouped odoc index — {N title} + {!modules: …} — turned into the side's module nav via Wodoc.Nav.api), a (heading …) (the API-nav title and switch-button label), the (wrapper <Module>) its modules sit under, and any (skip <title>)… section titles to drop. wodoc then gives every page its side's API nav, a body colour (wodoc-server / wodoc-client / …) and a client/server switch button. The manual nav is shared across the sides and comes from this config's (section …) blocks, exactly like a normal project. List the sibling Ocsigen projects in (hosted …) so their cross-references resolve.
An ultra-simple blog — (blog …)
A wodoc site can carry a blog with no extra tooling: a blog post is just a .mld page. Point (blog …) at a directory of posts and wodoc builds each like any other page, auto-lists them in a generated left-nav section (newest first) and expands a marker on the landing into a styled "latest posts" list.
(blog
(dir doc/blog) ; directory of post sources (default "blog")
(out blog) ; output subdirectory under the version root (default "blog")
(heading "Blog") ; the generated left-nav section heading (default "Blog")
(latest 5)) ; how many recent posts {%wodoc:blog-latest%} shows (default 5)Writing a post
A post is a plain .mld in (dir …), named YYYY-MM-DD-slug.mld:
doc/blog/2026-06-14-hello-world.mld- the publication date is the file-name prefix — no metadata file, and posts sort newest-first on it automatically;
- the title is the post's
{0 …}/{1 …}heading; - the author is odoc's native
@authortag (so it also shows on ocaml.org). odoc warns "Tags are not allowed in pages" for@authorin a.mldpage, but still renders it; the warning is harmless; - the excerpt shown in listings is the first body paragraph (markup stripped).
Posts deploy at <out>/<slug>.html and get the full site chrome, the shared menu and the blog-augmented left navigation, like every other page.
Latest posts on the landing
Place a marker where the recent-posts list should appear (typically the landing index.mld):
{%html:<!--wodoc-blog-latest-->%}This {%html:…%} form is honoured by stock odoc as an invisible HTML comment (so it is harmless on ocaml.org) and substituted by wodoc with the list. In a preprocessed build (a (mld-dir …) or (odoc-driver …) project) you may instead write the friendlier {%wodoc:blog-latest%}; both reach the same sentinel. The list is a <ul class="wodoc-blog-list"> of <li class="wodoc-blog-card">, each with a .wodoc-blog-title link, a .wodoc-blog-meta line (date — author) and a .wodoc-blog-excerpt. wodoc emits only this semantic markup; style it from your theme's stylesheet.
A page built through the low-level wodoc assemble path (rather than wodoc build) — typically a separately built site home — can carry the same listing with wodoc assemble … --blog-config <this config> --blog-base <path to the blog root>. For such a build, wodoc blog-nav prints the left-nav block and wodoc blog-feed prints an Atom feed of the posts (for syndication, e.g. OCaml Planet).
The version selector and versions.json
Every page carries a version <select> in its left column. Both wodoc build and wodoc release (re)write a versions.json manifest at the project root (the parent of the version directories, e.g. <site>/<project>/versions.json) — the single source of truth the page script reads to rebuild the menu on load. So even an old frozen page lists the current version set without being rewritten, and a release only has to touch this one file.
The format is small:
{"latest":"2.3","list":["dev","10.0.0","2.3","2.0""list"is the ordered menu:devfirst, then the version directories newest-first (numeric, so10.0.0sorts above2.0.0). A directory counts as a version only if it is itself a wodoc build (it carrieswodoc-highlight.js), so thelatestsymlink and stray asset dirs are skipped."latest"records the directory thelatestsymlink points at (ornullbefore the first release). That entry is labelled "<v>(latest)" and keeps the canonical/<project>/latest/URL when chosen; other versions navigate to their own numbered URL.
The menu is also baked statically into each page as a fallback, so it still works if the manifest cannot be fetched. You never write versions.json by hand — it is generated. See Wodoc.Build.write_manifest.