OCaml documentation as markdown

MAR 2026

DAVESNX

5 MINUTES

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 it was far from a great experience.

That split was the reason I wanted 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 the odoc's markdown backend and the dune integration because this is the direction I wanted OCaml tooling to move toward: integrated with the rest of the world and 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 like: What is Melange, Rationale, Getting Started, etc. We use odoc for the API reference for built-in libraries such as Belt, Js, and Stdlib.

odoc's HTML website for melange.beltodoc's HTML website for melange.belt

The current approach used odoc's HTML backend, which generates a standalone site linked from our documentation. That worked, but it 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, the sidebars were completely different (both content and design)
  • Search on melange.re could not find anything inside the generated HTML
  • Could not reference melange.re pages from inside .mld files
  • Language toggling was awkward because we needed separate HTML artifacts for Reason and OCaml, and it didn't persist when visiting the API references
  • And then there were all the smaller things: favicon, tracking scripts, syntax highlighting, plus 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, so another tool could build the final site from those generated files.

In our case, that tool was VitePress, but there are plenty of options for this, or 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 docsSame documentation as before (melange.belt), but as part of the melange.re docs

You can visit the result live 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>/. One module becomes one file, one .mld page becomes one file. The output looks something like this:

_doc/_markdown/
  my_lib/
    index.md
    My_module.md
    page.md

The simplest thing you can do is treat that directory as the thing you publish: upload _doc/_markdown wherever you need it or point a static host at the folder after the build.

When you want the markdown wired into a bigger pipeline, you have more options. 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. This is what I do in parseff, where a Starlight site consumes the promoted markdown. A simplified promote rule for 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 if you want the full wiring.

Once 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-runtestmdx-runtest

#Try it out and report any issue

Upgrade to dune.3.22 and odoc.3.1 and try it. Use the obvious cases, but also 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 falls out of having OCaml docs as Markdown. If you hit edge cases, please report them in the GitHub tracker: https://github.com/ocaml/odoc and tag me @davesnx.

This feature is still relatively experimental. Real-world feedback, especially unusual use cases, is what 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