My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Interactive OCaml Tutorials — Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a system where tutorial authors write .mld files with tagged code blocks, run dune build @doc, and get HTML pages with interactive, executable OCaml cells.

Architecture: Thin odoc plugin (Approach C) translates {@ocaml ...} tags to <x-ocaml> HTML elements with data attributes. The x-ocaml WebComponent handles all interactive behaviour. Package universes are built from opam switches via the existing jtw tool and discovered at runtime via findlib_index.json.

Tech Stack: OCaml, js_of_ocaml, odoc (fork with extension API), dune-site plugins, x-ocaml WebComponent, js_top_worker

Repo: /home/jons-agent/workspace/mono (monorepo managed by monopam)


Work Streams#

The work is organised into four independent streams. Streams 1-3 can proceed in parallel. Stream 4 (FRP experiments) is exploratory and independent.

Stream 1: odoc-interactive-extension (the thin plugin)#

Stream 2: x-ocaml cell modes (exercise/test/hidden/interactive)#

Stream 3: Universe builder improvements (findlib_index.json)#

Stream 4: Widget/FRP bridge experiments#


Stream 1: odoc-interactive-extension#

Create a new odoc extension plugin that handles {@ocaml ...} code blocks with interactive attributes and @x-ocaml.* custom tags.

Current state#

  • The odoc extension API exists at odoc/src/extension_api/odoc_extension_api.ml
  • odoc-scrollycode-extension/ is a working reference implementation
  • Code blocks with attributes like {@ocaml foo=bar [...]} are already parsed by odoc's parser into code_block_meta with language and tags fields
  • The Code_Block_Extension module type handles code block transformation
  • The Extension module type handles custom @tag processing
  • Resources (JS/CSS URLs) are collected and injected into page <head>

Key files to reference#

  • odoc-scrollycode-extension/src/scrollycode_extension.ml — reference plugin implementation
  • odoc-scrollycode-extension/src/dune — dune config for plugin registration
  • odoc/src/extension_api/odoc_extension_api.ml — the API to implement
  • odoc/src/extension_registry/odoc_extension_registry.ml — registry internals
  • odoc/src/document/comment.ml:257-307 — where code block extensions are dispatched

Task 1.1: Scaffold odoc-interactive-extension directory#

Files:

  • Create: odoc-interactive-extension/dune-project
  • Create: odoc-interactive-extension/src/dune
  • Create: odoc-interactive-extension/src/interactive_extension.ml

Step 1: Create dune-project

(lang dune 3.18)
(using dune_site 0.1)
(name odoc-interactive-extension)
(generate_opam_files true)

(package
 (name odoc-interactive-extension)
 (synopsis "Interactive OCaml code cells for odoc documentation")
 (depends
  (ocaml (>= 4.14))
  odoc))

Step 2: Create src/dune

Follow the scrollycode pattern exactly:

(library
 (public_name odoc-interactive-extension.impl)
 (name interactive_extension)
 (libraries odoc.extension_api))

(plugin
 (name odoc-interactive-extension)
 (libraries odoc-interactive-extension.impl)
 (site (odoc extensions)))

Step 3: Create minimal interactive_extension.ml

Start with a skeleton that registers both a Code_Block_Extension (for {@ocaml ...}) and an Extension (for @x-ocaml.* tags):

open Odoc_extension_api

(* Page-level config accumulated during processing *)
let universe_url = ref None
let requires = ref []
let auto_execute = ref true
let merlin_enabled = ref true

module X_ocaml_config : Extension = struct
  let prefix = "x-ocaml"

  let to_document ~tag content =
    (* tag will be "x-ocaml.universe", "x-ocaml.requires", etc. *)
    let subtag = match String.split_on_char '.' tag with
      | _ :: rest -> String.concat "." rest
      | _ -> tag
    in
    let text = (* extract text from content *) "" in
    (match subtag with
     | "universe" -> universe_url := Some text
     | "requires" -> requires := String.split_on_char ',' text
     | "auto-execute" -> auto_execute := (text <> "false")
     | "merlin" -> merlin_enabled := (text <> "false")
     | _ -> ());
    (* Emit nothing visible *)
    { content = []; overrides = []; resources = []; assets = [] }
end

module X_ocaml_code : Code_Block_Extension = struct
  let prefix = "ocaml"

  let to_document meta code =
    let tags = meta.tags in
    (* Extract mode: interactive, exercise, test, hidden *)
    let mode = (* ... find mode tag ... *) "interactive" in
    let id_attr = (* ... find id=xxx binding ... *) None in
    let for_attr = (* ... find for=xxx binding ... *) None in
    let merlin_attr = (* ... find merlin tag ... *) None in
    (* Build data attributes string *)
    let attrs = String.concat " "
      (List.filter_map Fun.id [
        Some (Printf.sprintf "mode=\"%s\"" mode);
        Option.map (Printf.sprintf "data-id=\"%s\"") id_attr;
        Option.map (Printf.sprintf "data-for=\"%s\"") for_attr;
        Option.map (fun _ -> "data-merlin") merlin_attr;
      ])
    in
    let html = Printf.sprintf "<x-ocaml %s>\n%s\n</x-ocaml>" attrs code in
    Some {
      content = [{ attr = []; desc = Raw_markup ("html", html) }];
      overrides = [];
      resources = [
        (* x-ocaml.js — URL will come from universe config *)
        Js_url "x-ocaml.js";
      ];
      assets = [];
    }
end

let () =
  Registry.register (module X_ocaml_config);
  Registry.register_code_block (module X_ocaml_code)

Step 4: Build to verify compilation

Run: opam exec -- dune build -p odoc-interactive-extension

Expected: Compiles without errors (may need adjustments to match exact API signatures — check odoc_extension_api.mli for precise types)

Step 5: Commit

git add odoc-interactive-extension/
git commit -m "feat: scaffold odoc-interactive-extension plugin"

Task 1.2: Implement @x-ocaml.* config tag handling#

Files:

  • Modify: odoc-interactive-extension/src/interactive_extension.ml

Step 1: Study how scrollycode extracts text from tag content

Read odoc-scrollycode-extension/src/scrollycode_extension.ml to see how to_document ~tag content extracts text from the nestable_block_element list. The content is structured AST, not plain text.

Step 2: Implement text extraction from tag content

Write a helper that extracts plain text from the nestable block element list (the tag payload).

Step 3: Implement meta tag emission

The @x-ocaml.universe tag should emit a <meta> tag in the page. Since odoc resource injection happens in <head>, this may need to use Raw_markup in the content, or a Js_inline resource containing a meta tag injection script. Investigate which approach works.

Step 4: Build and test with a sample .mld file

Create a test .mld file with @x-ocaml.universe and verify the generated HTML contains the right meta tag.

Step 5: Commit

Task 1.3: Implement code block transformation#

Files:

  • Modify: odoc-interactive-extension/src/interactive_extension.ml

Step 1: Study the Code_Block_Extension API precisely

Read odoc/src/extension_api/odoc_extension_api.ml — look at the code_block_meta type and code_block_tags to understand how {@ocaml exercise id=factorial [...]} is parsed. The tags will be a list of Tag (bare) and Binding (key=value) entries.

Step 2: Implement tag extraction

Write helpers to extract:

  • Mode: first bare tag that matches interactive|exercise|test|hidden
  • id: from Binding("id", value)
  • for: from Binding("for", value)
  • merlin: bare tag presence
  • env: from Binding("env", value)

Step 3: Implement HTML emission

Generate <x-ocaml> elements with the appropriate data attributes. The code content goes as the text content of the element. Make sure to HTML-escape the code content.

Step 4: Handle the script tag injection

The x-ocaml.js script should be injected once per page via resources. The URL should reference the universe base URL if configured, otherwise use a relative path. Investigate whether the resource URL can be dynamic (based on the @x-ocaml.universe config) or must be static.

Step 5: Build and test with sample .mld

Create a test .mld file with various cell types:

@x-ocaml.universe ./universe

{@ocaml hidden [let helper x = x + 1]}

{@ocaml interactive [let x = helper 41]}

{@ocaml exercise id=double [
let double x =
    (* YOUR CODE HERE *)
    failwith "Not implemented"
]}

{@ocaml test for=double [assert (double 5 = 10)]}

Run dune build @doc and inspect the generated HTML to verify:

  • Hidden cell emits <x-ocaml mode="hidden">
  • Interactive cell emits <x-ocaml mode="interactive">
  • Exercise cell emits <x-ocaml mode="exercise" data-id="double">
  • Test cell emits <x-ocaml mode="test" data-for="double">
  • Script tag is injected once
  • Meta tag for universe URL is present

Step 6: Commit

Task 1.4: Add to monorepo build#

Files:

  • Modify: dune-project (root) — add package
  • Modify: sources.toml — if needed for monopam

Step 1: Add the package to the root dune-project

The monorepo's root dune-project needs to know about the new package. Check how other extension packages are registered.

Step 2: Verify it builds in the monorepo context

Run: opam exec -- dune build

Step 3: Commit


Stream 2: x-ocaml cell modes#

Extend the x-ocaml WebComponent to support mode, data-id, data-for, data-env, data-merlin, and data-auto-execute attributes.

Current state#

  • x-ocaml currently reads attributes only from the <script> tag, not from individual <x-ocaml> elements
  • Per-element, only run-on is read (click vs load)
  • Cells form a linked list (prev/next) with implicit sequential execution
  • All cells are fully editable with CodeMirror
  • No concept of modes, hidden cells, or test cells
  • Cell status is: Not_run | Running | Run_ok | Request_run

Key files#

  • x-ocaml/src/x_ocaml.ml — main entry, WebComponent registration, global state
  • x-ocaml/src/cell.ml — individual cell UI, editor, execution
  • x-ocaml/src/editor.ml — CodeMirror wrapper
  • x-ocaml/src/webcomponent.ml — custom element definition, shadow DOM, attribute reading
  • x-ocaml/src/backend.ml — worker communication abstraction

Task 2.1: Read mode and data attributes from elements#

Files:

  • Modify: x-ocaml/src/cell.ml
  • Modify: x-ocaml/src/x_ocaml.ml

Step 1: Read the current cell initialisation code

Read cell.ml — specifically Cell.init and Cell.start — to understand how cells are created and what data they hold. Read x_ocaml.ml to see how the connectedCallback creates cells.

Step 2: Add mode type and reading

Add a mode type to cell.ml:

type mode = Interactive | Exercise | Test | Hidden

Read mode from the element's mode attribute in the connectedCallback. Default to Interactive if not present.

Step 3: Read data attributes

Read data-id, data-for, data-env, data-merlin from the element. Store them in the cell record.

Step 4: Build and verify

Run: opam exec -- dune build

Step 5: Commit

Task 2.2: Implement hidden cell behaviour#

Files:

  • Modify: x-ocaml/src/cell.ml

Step 1: Skip rendering for hidden cells

In the cell initialization, if mode = Hidden:

  • Don't create the CodeMirror editor
  • Don't create the run button
  • Don't attach to shadow DOM (or set display: none)
  • Still register in the cell linked list for execution ordering
  • Still hold the code text from the element's textContent

Step 2: Ensure hidden cells execute

Hidden cells should still execute their code (they provide setup definitions). When a subsequent cell runs and triggers the linked list dependency chain, hidden cells should execute silently.

Step 3: Test manually

Create an HTML file with:

<x-ocaml mode="hidden">let helper x = x + 1</x-ocaml>
<x-ocaml mode="interactive">helper 41</x-ocaml>

Verify the hidden cell is not visible and helper 41 evaluates to 42.

Step 4: Commit

Task 2.3: Implement exercise cell behaviour#

Files:

  • Modify: x-ocaml/src/cell.ml

Step 1: Make exercise cells editable, others read-only

Currently all cells are editable. Change this:

  • Exercise: editable (keep current behaviour)
  • Interactive: editable but could be made read-only — check design doc. Design says "No" for editable, so set CodeMirror to read-only.
  • Test: read-only
  • Hidden: no editor

Use CodeMirror's readOnly extension/config to control this.

Step 2: Visual differentiation

Add CSS classes or styles to distinguish exercise cells:

  • Exercise cells could have a light background tint or border indicating "edit here"
  • Test cells could have a different tint indicating "assertion"

Keep it minimal — just enough to distinguish cell types visually.

Step 3: Test manually

Create an HTML page with exercise + test cells and verify:

  • Exercise cell is editable
  • Test cell is read-only
  • Visual distinction is clear

Step 4: Commit

Task 2.4: Implement test cell linking#

Files:

  • Modify: x-ocaml/src/cell.ml
  • Modify: x-ocaml/src/x_ocaml.ml

Step 1: Implement positional linking

After all cells are registered (in the connectedCallback or a post-registration pass), for each test cell without a data-for attribute, find the nearest preceding exercise cell and record the association.

Step 2: Implement explicit linking

For test cells with data-for="name", find the cell with data-id="name" and record the association.

Step 3: Implement test execution trigger

When an exercise cell is executed (user clicks Run), automatically execute its associated test cells afterwards. Display pass/fail results in the test cell's output area.

Step 4: Test manually

Create an HTML page with the factorial exercise example. Verify:

  • Filling in the correct implementation and running it triggers the test cell
  • Tests passing shows success
  • Tests failing shows the assertion error

Step 5: Commit

Task 2.5: Implement per-cell merlin override#

Files:

  • Modify: x-ocaml/src/cell.ml
  • Modify: x-ocaml/src/x_ocaml.ml

Step 1: Read page-level merlin setting

The page-level data-merlin (from <meta> tag or <script> tag attribute) sets the default. Read this in x_ocaml.ml.

Step 2: Allow per-cell override

If an <x-ocaml> element has data-merlin="true" or data-merlin="false", override the page default for that cell.

Step 3: Skip merlin initialisation for disabled cells

In cell.ml, when creating the Merlin client, check the cell's merlin setting. If disabled, don't create the Merlin worker or register error/completion callbacks.

Step 4: Test and commit

Task 2.6: Implement universe discovery from meta tag#

Files:

  • Modify: x-ocaml/src/x_ocaml.ml

Step 1: Read universe URL from meta tag

Currently x-ocaml reads src-worker from the <script> tag. Add logic to also check for:

<meta name="x-ocaml-universe" content="https://...">

If present, derive the worker URL from the universe base ({universe}/worker.js).

Step 2: Fallback chain

  1. <meta name="x-ocaml-universe"> → use {url}/worker.js
  2. src-worker attribute on <script> tag (existing behaviour)
  3. Relative ./universe/worker.js (default)

Step 3: Pass universe URL to worker for findlib_index.json

The worker needs to know where to find findlib_index.json. This may require extending the init RPC call to accept the findlib_index URL (currently it uses a relative path).

Step 4: Test and commit


Stream 3: Universe builder improvements#

Improve the jtw opam command to produce the universe layout from the design doc, using findlib_index.json.

Current state#

  • jtw opam exists in js_top_worker/bin/jtw.ml (lines 499-535)
  • It walks an opam switch, copies CMIs, compiles cma.js, generates findlib_index (not .json) and dynamic_cmis.json
  • The JSON format currently uses "metas" key (not "meta_files")
  • findlibish.ml parser accepts both "metas" and "meta_files"
  • The tool already handles most of what's needed

Key files#

  • js_top_worker/bin/jtw.ml — the CLI tool (571 lines)
  • js_top_worker/bin/ocamlfind.ml — queries ocamlfind for packages
  • js_top_worker/lib/findlibish.ml — findlib_index parser/loader

Task 3.1: Rename findlib_index to findlib_index.json#

Files:

  • Modify: js_top_worker/bin/jtw.ml
  • Modify: js_top_worker/lib/findlibish.ml

Step 1: Read the current code

Read jtw.ml to find where findlib_index filenames are generated. Read findlibish.ml to find where findlib_index is expected.

Step 2: Update output filename

In jtw.ml, change the output filename from findlib_index to findlib_index.json wherever universes are written.

Step 3: Update default input path

In findlibish.ml (or wherever the default findlib_index path is set), update to look for findlib_index.json. Keep backward compatibility: if findlib_index.json doesn't exist, try findlib_index.

Step 4: Standardise JSON key names

If the generated JSON uses "metas", also emit "meta_files" for consistency with the design doc. The parser already accepts both.

Step 5: Build and test

Run: opam exec -- dune build

If there are existing tests for jtw, run them.

Step 6: Commit

Task 3.2: Include worker.js and x-ocaml.js in universe output#

Files:

  • Modify: js_top_worker/bin/jtw.ml

Step 1: Study current worker.js generation

The jtw opam command has a --no-worker flag, meaning it can already include worker.js. Read the code to understand how Mk_backend.mk works and where the worker.js gets placed.

Step 2: Add x-ocaml.js to output

When generating a universe, also copy x-ocaml.js into the output directory alongside worker.js. This may require a new flag (--x-ocaml-js PATH) or auto-detection.

Step 3: Update the design doc if needed

If the implementation differs from the design doc's universe layout, update the design doc.

Step 4: Build and test

Step 5: Commit

Task 3.3: Add @x-ocaml.requires support to worker init#

Files:

  • Modify: js_top_worker/lib/impl.ml

Step 1: Study current requires handling

The init RPC already accepts findlib_requires in init_config (impl.ml line 655+). Check whether x-ocaml currently passes requires through.

Step 2: Verify the flow

Trace the path from x-ocaml reading @x-ocaml.requires (from <meta> tag) through to the worker's init/setup calls. If x-ocaml already has a way to pass requires, document it. If not, identify where the integration point is.

Step 3: Implement if needed

If x-ocaml doesn't currently pass requires to the worker init, add this capability.

Step 4: Test and commit


Stream 4: Widget/FRP bridge experiments#

These are exploratory — the goal is to evaluate Lwd and Note for the widget architecture, not to build production code.

Task 4.1: Set up experiment scaffolding#

Files:

  • Create: experiments/widget-bridge/dune-project
  • Create: experiments/widget-bridge/README.md

Step 1: Create experiment directory

Set up a minimal dune project outside the monorepo (or in a subdirectory) that can build js_of_ocaml targets and use web workers.

Step 2: Define the serializable view type

Create a simple, serializable view description type:

type event_id = string

type attr =
  | Property of string * string
  | Style of string * string
  | Class of string
  | On of string * event_id  (* event name, handler id *)

type node =
  | Text of string
  | Element of { tag : string; attrs : attr list; children : node list }

(* Serialise to JSON for postMessage *)
val to_json : node -> string
val of_json : string -> node

Step 3: Define the event message type

type event_msg = {
  handler_id : event_id;
  event_type : string;
  value : string option;  (* for input elements *)
}

Step 4: Commit

Task 4.2: Experiment A — Counter with Lwd#

Files:

  • Create: experiments/widget-bridge/lwd_counter/

Step 1: Install Lwd

Run: opam install lwd

Step 2: Write the worker-side counter

Using Lwd, create a counter:

  • Lwd.var for the count
  • Derived Lwd.t that produces a node view description
  • On "increment" event, update the var
  • On each change, serialize the view and post it

Step 3: Write the main-thread renderer

A small JS/OCaml module that:

  • Receives serialized view descriptions
  • Diffs against current DOM (or just replaces)
  • Attaches event listeners that send messages back

Step 4: Measure and document

  • Code size (lines of OCaml)
  • Bundle size impact
  • Round-trip latency (click → updated DOM)
  • Ergonomics notes

Step 5: Commit

Task 4.3: Experiment B — Counter with Note#

Files:

  • Create: experiments/widget-bridge/note_counter/

Same as Task 4.2 but using Note's signals and events. Compare:

  • Note.S.create for the count signal
  • Note.E.create for click events
  • Note.S.map to derive the view

Document comparison with Lwd version.

Task 4.4: Experiment C — Richer widget (two sliders)#

Files:

  • Create: experiments/widget-bridge/sliders/

Using whichever library felt better from A/B, build:

  • Two sliders that control x and y
  • A computed display showing x * y
  • Tests composition and multiple interacting inputs

Task 4.5: Experiment D — TyXML backend#

Files:

  • Create: experiments/widget-bridge/tyxml_backend/

Try instantiating TyXML's Html_f.Make functor with a custom Xml_sigs.T implementation that produces the serializable node type. Evaluate whether the type safety is worth the boilerplate.


Dependency graph#

Stream 1 (odoc plugin) ──────────────────────┐
                                              │
Stream 2 (x-ocaml modes) ───────────────────┼──> End-to-end demo
                                              │
Stream 3 (universe builder) ─────────────────┘

Stream 4 (FRP experiments) ──────────────────────> Decision on widget arch

Streams 1, 2, 3 converge in an end-to-end integration test: write an .mld with exercises, build with dune build @doc, serve the output alongside a universe, and verify the interactive tutorial works in a browser.

Stream 4 is independent exploratory work.

End-to-end integration test#

After Streams 1-3 are complete:

  1. Build a universe from the current opam switch using jtw opam
  2. Write a sample .mld tutorial with hidden setup, interactive demos, exercises, and tests
  3. Run dune build @doc with the interactive extension installed
  4. Serve the generated HTML + universe with a local HTTP server
  5. Open in browser and verify:
    • Hidden cells are invisible but their definitions are available
    • Interactive cells display pre-filled code
    • Exercise cells are editable
    • Running an exercise triggers associated test cells
    • Test pass/fail is clearly displayed
    • Merlin provides completions in exercise cells