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

(project <id>)

required — package / project id, e.g. ocsipersist

(title <label>)

sub-project label shown by the logo (default: the project id)

(url-prefix <path>)

absolute publish base for the version selector, e.g. /ocsipersist (default /<project>)

(menu-current <id>)

which project to highlight in the shared top menu (default: the project id)

(packages <p> …)

the odoc output subtrees to assemble, in order; empty = every subtree odoc produced

(landing <path>)

where the version-root index.html redirects, e.g. ocsipersist/index.html

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-page base is prefixed automatically), unless it is absolute (/talks/…) or a URL (https://…), in which case it is emitted verbatim. <current> is an optional data-wodoc-page id: 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 at ocaml.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) or subdir (a <pkg>/ subtree per package, e.g. js_of_ocaml, tyxml); true/false are accepted as legacy aliases for multilib/root. <Wrapper> is its wrapper module (or "").

Build inputs and assets

Stanza

Meaning

(odoc-driver <pkg>)

build the API with odoc_driver <pkg> --remap (the engine ocaml.org uses) instead of dune build @doc; needed when <pkg>.server/<pkg>.client share module names

(profile <p>)

dune build profile (e.g. release) for the dune build @doc

(doc-manual true)

also build the @doc-manual alias (examples)

(manual-files <pkg>)

package dir that receives manual/files (examples, images)

(highlight <file>)

a project-specific syntax-highlight starter to ship as wodoc-highlight.js; without it, wodoc ships a built-in default covering eliom / lwt / js_of_ocaml syntax

(markdown false)

turn off the Markdown twins and llms.txt index (on by default; see Commands)

(css <href> …)

the page stylesheets. A relative href is copied into the build and linked per-version (self-contained, works at any path); an absolute (/…) or URL href is emitted verbatim. Omitted → wodoc ships its built-in default theme as wodoc.css

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 @author tag (so it also shows on ocaml.org). odoc warns "Tags are not allowed in pages" for @author in a .mld page, 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: dev first, then the version directories newest-first (numeric, so 10.0.0 sorts above 2.0.0). A directory counts as a version only if it is itself a wodoc build (it carries wodoc-highlight.js), so the latest symlink and stray asset dirs are skipped.
  • "latest" records the directory the latest symlink points at (or null before 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.