Writing Extensions
odoc supports a plugin system for custom tags and code blocks. Extensions are OCaml libraries loaded at doc-generation time that transform custom markup into HTML, LaTeX, or other output formats.
This guide covers the practical aspects of writing an extension, with particular attention to the pitfalls around JavaScript and SPA navigation.
Extension Types
There are two kinds of extension:
- Tag extensions handle custom tags like
@@note,@@rfc,@@scrolly. They receive the tag's content as a list of block elements and return document content, resources, and assets. - Code block extensions handle fenced code blocks with a custom language, e.g.,
{@@dot ...}or{@@ocaml ...}. They receive the code text plus any options and return the same output types.
Both are registered as dune-site plugins and discovered automatically.
The Extension Interface
A tag extension implements the Odoc_extension_api.Extension signature:
module My_ext : Odoc_extension_api.Extension = struct
let prefix = "my-ext"
let to_document ~tag content =
let html = (* ... generate HTML from content ... *) in
{
Odoc_extension_api.content = [ { attr = []; desc = Raw_markup ("html", html) } ];
overrides = [];
resources = [
Css_url "extensions/my-ext.css";
Js_url "extensions/my-ext.js";
];
assets = [];
}
endRegister it alongside any support files:
let () =
Odoc_extension_api.Registry.register (module My_ext);
Odoc_extension_api.Registry.register_support_file ~prefix:"my-ext" {
filename = "extensions/my-ext.css";
content = Inline my_css_string;
};
Odoc_extension_api.Registry.register_support_file ~prefix:"my-ext" {
filename = "extensions/my-ext.js";
content = Inline my_js_string;
}Resources and the HTML <head>
Extensions declare page-level resources (JavaScript, CSS) that are injected into <head>. There are four resource types:
Type | Rendered as | SPA behaviour |
|---|---|---|
|
| Deduplicated by URL; loaded at most once |
|
| Deduplicated by URL; loaded at most once |
|
| Executed once; skipped if hash already in DOM |
|
| Re-injected every navigation (idempotent) |
Prefer Js_url over inline scripts
Put your runtime JavaScript in a support file and reference it with Js_url. This gives you:
- Clean separation of concerns (JS in a
.jsfile, not an OCaml string) - Proper browser caching
- Correct deduplication across SPA navigations
- The script loads once and stays alive for the lifetime of the page
Use Js_inline only for small bootstrapping snippets that must run once (e.g., injecting a <meta> tag). Never put your main runtime in an inline script.
SPA Navigation: The Critical Pitfall
The odoc docsite shell (and similar shells) implement single-page app navigation: clicking a sidebar link fetches the target page via fetch(), swaps the content area, and updates the URL with history.pushState. No full page reload occurs.
This has important consequences for extensions that include JavaScript:
The problem
Consider this naive approach — embedding JavaScript directly into the generated HTML body:
(* BAD: Inline <script> in the body HTML *)
let html = Printf.sprintf {|
<div class="my-widget">...</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
initMyWidget(document.querySelector('.my-widget'));
});
</script>
|} in
{ content = [{ attr = []; desc = Raw_markup ("html", html) }]; ... }This breaks under SPA navigation for two reasons:
- The shell swaps only the content area (
.odoc-content). Body scripts from the fetched page are not executed — the shell only processes scripts found in<head>. - Even if the script were in
<head>,DOMContentLoadedfires only once per page lifecycle. On SPA navigation the event never re-fires, so the initialisation function never runs.
The result: the extension works on a full page load (e.g., opening the URL directly), but silently fails when the user navigates to the page via a sidebar link. This is particularly insidious because it only manifests in certain navigation paths.
The solution
Move your JavaScript to a head-loaded support file. Inside it, handle both initial load and subsequent SPA navigations:
// extensions/my-ext.js — loaded via Js_url
(function() {
'use strict';
function initWidget(container) {
// ... set up event listeners, observers, etc. ...
}
// Initialise any uninitialised widgets on the page.
function initAll() {
document.querySelectorAll('.my-widget').forEach(function(el) {
if (!el.dataset.myInit) {
el.dataset.myInit = '1';
initWidget(el);
}
});
}
// Run on initial page load.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
initAll();
observe();
});
} else {
initAll();
observe();
}
// Watch for new content injected by SPA navigation.
function observe() {
new MutationObserver(function() { initAll(); })
.observe(document.body, { childList: true, subtree: true });
}
})();Key points:
- Guard against double-init. Use a
data-*attribute to mark initialised elements. TheMutationObserverfires on every DOM mutation, soinitAllmay be called many times. - Check
document.readyState. The script is in<head>, sodocument.bodydoesn't exist yet on the initial load. Wait forDOMContentLoadedbefore attaching theMutationObserver. - Don't rely on
DOMContentLoadedalone. After SPA navigation theJs_urlscript has already loaded andDOMContentLoadedalready fired. TheMutationObserveris what detects the new content.
Case study: Scrollycode
The scrollycode extension provides scroll-driven code tutorials. As users scroll through explanatory steps, an IntersectionObserver detects which step is visible and updates a sticky code panel.
Initially, the scrollycode runtime was embedded as an inline <script> in the generated HTML body, gated on DOMContentLoaded:
(* OLD — broken under SPA navigation *)
Buffer.add_string buf "<script>\n";
Buffer.add_string buf shared_js; (* contains DOMContentLoaded listener *)
Buffer.add_string buf "</script>\n";This worked perfectly on direct page loads. But when a user navigated to a scrollycode page via the sidebar:
- The shell swapped the content area, inserting the scrollycode HTML.
- The body
<script>was not executed (the shell only processes head scripts). - The
IntersectionObserverwas never set up. - The code panel stayed frozen on step 1 regardless of scroll position.
The fix was to:
Register the JS as a support file and reference it via
Js_url:Odoc_extension_api.Registry.register_support_file ~prefix:"scrolly" { filename = "extensions/scrollycode.js"; content = Inline shared_js; }; (* In resources: *) resources = [ Js_url "extensions/scrollycode.js"; ... ];- Replace the
DOMContentLoadedgate withreadyStatecheck +MutationObserver(as shown in the pattern above). - Add a
data-sc-initguard on each.sc-containerto prevent double-initialisation.
Testing Extensions
Test your extension under both navigation modes:
- Direct load: Open the URL directly in the browser. This is the easy case and usually works.
- SPA navigation: Start on a different page in the same documentation site, then click a sidebar link to navigate to a page using your extension. This is where body-script and
DOMContentLoadedbugs surface.
Automated testing with Playwright (or similar) should cover both paths:
// Direct load
await page.goto('/my-extension-page.html');
expect(await page.locator('.my-widget').getAttribute('data-my-init')).toBe('1');
// SPA navigation
await page.goto('/some-other-page.html');
await page.click('a[href*="my-extension-page"]');
await page.waitForTimeout(500);
expect(await page.locator('.my-widget').getAttribute('data-my-init')).toBe('1');Checklist
Before shipping an extension, verify:
- No body scripts. All JavaScript is delivered via
Js_url(support files) or smallJs_inlinebootstraps inresources. Nothing is embedded in the HTML body viaRaw_markup. - No
DOMContentLoadeddependency. Usedocument.readyStatecheck +MutationObserverinstead. - Double-init guard. Every element you initialise is marked (e.g., with a
data-*attribute) and skipped on subsequentinitAllcalls. - SPA navigation tested. Both direct-load and sidebar-navigation paths work.
MutationObserverset up afterdocument.bodyexists. If your script is in<head>,document.bodyisnullon initial parse.