jon.recoil.org

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:

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 = [];
    }
end

Register 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

Js_url "path.js"

<script src="...">

Deduplicated by URL; loaded at most once

Css_url "path.css"

<link rel="stylesheet" href="...">

Deduplicated by URL; loaded at most once

Js_inline code

<script data-spa-inline="hash">code</script>

Executed once; skipped if hash already in DOM

Css_inline code

<style>code</style>

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:

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:

  1. 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>.
  2. Even if the script were in <head>, DOMContentLoaded fires 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:

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:

  1. The shell swapped the content area, inserting the scrollycode HTML.
  2. The body <script> was not executed (the shell only processes head scripts).
  3. The IntersectionObserver was never set up.
  4. The code panel stayed frozen on step 1 regardless of scroll position.

The fix was to:

  1. 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"; ... ];
  2. Replace the DOMContentLoaded gate with readyState check + MutationObserver (as shown in the pattern above).
  3. Add a data-sc-init guard on each .sc-container to prevent double-initialisation.

Testing Extensions

Test your extension under both navigation modes:

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: