For a while, Melange had two documentation sites. The guides lived in VitePress, while the API reference lived in odoc's generated HTML. It worked, but the experience was far from great.
That split made me want markdown output from odoc. With dune 3.22 and odoc 3.1, dune build @doc-markdown now turns .mli and .mld files into Markdown files.
dune build @doc-markdownI implemented both odoc's markdown backend and the dune integration because I wanted OCaml tooling to integrate with the rest of the world and become more modern. I built it for Melange first, then started using the same flow in smaller libraries too.
In this post, I want to explain why and how you can use it too.
Why I cared
In Melange 5.0.0, we use VitePress to write most of the manual documentation, with pages such as What is Melange, Rationale, and Getting Started. We use odoc for the API reference for built-in libraries such as Belt, Js, and Stdlib.
odoc's HTML website for melange.belt
That approach used odoc's HTML backend, which generates a standalone site linked from our documentation. It worked, but created a big UX barrier between the two sites:
- Users went from a unified experience to a different one:
melange.reis a SPA, while the generated API docs are a separate MPA with different styling - Navigation was not shared, and the sidebars were completely different in both content and design
- Search on
melange.recould not find anything inside the generated HTML .mldfiles could not referencemelange.repages- Language toggling was awkward because we needed separate HTML artifacts for Reason and OCaml, and it did not persist when visiting the API references
- We also had to handle smaller details such as the favicon, tracking scripts, and syntax highlighting, along with the rest of the features a static site generator gives you
For those reasons, I wanted one documentation site with the API reference inside. The missing piece was markdown output from odoc, which would let another tool build the final site from those generated files.
In our case, that tool was VitePress, but other options include Docusaurus, MkDocs, Sphinx, Starlight, Jekyll, Hugo, and even the GitHub markdown viewer.
Those documentation generators are powerful, and I do not expect odoc to match everything they do. Markdown output lets odoc plug into the rest of the docs stack instead of competing with it: developers already read it on GitHub, publishing systems can consume it directly, and AI tooling is generally easier to integrate with Markdown than a generated HTML site.
What this looks like at the end
Same documentation as before (melange.belt), but as part of the melange.re docs
You can visit the result at https://melange.re/unstable/api.html.
How does it work
Running dune build @doc-markdown reads your .mli and .mld files, passes them through odoc, and produces markdown files under _build/default/_doc/_markdown/<package>/. Each module becomes one file, and each .mld page becomes one file. The output looks like this:
_doc/_markdown/
my_lib/
index.md
My_module.md
page.mdThe simplest option is to publish that directory: upload _doc/_markdown wherever you need it, or point a static host at the folder after the build.
You have more options when wiring the markdown into a bigger pipeline. You can promote generated files into your source tree with (promote (until-clean)) so a static-site generator sees them as normal files next to your hand-written docs. Running dune clean removes the promoted copies so they do not go stale. I use this approach in parseff, where a Starlight site consumes the promoted markdown. This simplified promote rule handles a single page:
(rule
(alias doc-markdown)
(mode (promote (until-clean)))
(deps %{workspace_root}/_doc/_markdown/my_lib/page.md)
(targets page.md)
(action (copy %{first-dep} %{targets})))Both parseff and html_of_jsx run dune build @doc-markdown in GitHub Actions on push to main, then build and deploy the site. Check each repo's workflow for the full wiring.
After I had the markdown output, I started using the same flow in smaller libraries too. One of my favorite uses is generating README.md from README.mld.
Generate README.md from README.mld
In parseff, I use README.mld as the source and generate README.md for GitHub.
The relevant dune setup looks like this:
(documentation
(package parseff)
(mld_files README))
(rule
(alias doc-markdown) ; This rule uses a known alias `doc-markdown`,
; it ensures this runs on each original `@doc-markdown`
(mode (promote (until-clean)))
(deps %{workspace_root}/_doc/_markdown/parseff/README.md)
(targets README.md)
; This action adds a header with printf since I wanted to make it clear for users
; but it's not really needed to generate a README.md can use `(copy)`
(action
(system
"printf
'<!-- Please do not edit this file directly. \
Update README.mld instead. -->\\n\\n' > %{targets} \
&& cat %{workspace_root}/_doc/_markdown/parseff/README.md \
>> %{targets}")))Testing your documentation code snippets
The next step for me was executable examples. You can combine odoc with mdx, which lets you execute code blocks as part of your test suite. That keeps the documentation correct and in sync with the real source.
(mdx
(files README.mld lib/your_lib.mli)
(libraries your_lib))dune runtestYou can also check toplevel examples this way. mdx treats them like cram tests, so dune runtest --auto-promote can update the expected output for you.
(** These examples are checked by mdx:
{@ocaml[
# 1 + 2;;
- : int = 3
# "a" ^ "bc";;
- : string = "abc"
]}
*)mdx-runtest
Try it out and report any issue
Upgrade to dune.3.22 and odoc.3.1 and try it. Use the obvious cases and explore the less obvious ones: pipe the output into your docs site, generate a README.md, check examples with mdx, or try anything else that comes from having OCaml docs as Markdown. If you hit edge cases, please report them in the GitHub tracker at https://github.com/ocaml/odoc and tag me @davesnx.
This feature is still relatively experimental. Real-world feedback, especially unusual use cases, will improve these workflows.
References
- dune latests releases: github.com/ocaml/dune/releases
- PR to add
@doc-markdownalias into dune: github.com/ocaml/dune/pull/12581 - odoc issue (2018): github.com/ocaml/odoc/issues/121
- odoc PR (2021, historical): github.com/ocaml/odoc/pull/791
- OCaml Discuss thread: 2025 Documentation best practices
- Parseff: github.com/davesnx/parseff
- html_of_jsx: github.com/davesnx/html_of_jsx
Credits
Thanks to @jonludlam, @rgrinberg, and @Alizter for reviewing the work that made it possible.