OCaml documentation as markdown

Turn your OCaml API documentation into publishable Markdown

MAR 2026

5 MINUTES

DAVESNX

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

I 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.re is 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.re could not find anything inside the generated HTML
  • .mld files could not reference melange.re pages
  • 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.md

The 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 runtest

You 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

Credits

Thanks to @jonludlam, @rgrinberg, and @Alizter for reviewing the work that made it possible.

Thanks for reading!
Any feedback is appreciated.

@davesnx