My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

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

feat: add odoc-interactive-extension plugin for x-ocaml code cells

Transforms {@ocaml ...} code blocks into <x-ocaml> custom elements and
handles @x-ocaml.universe/@x-ocaml.requires config tags for page-level
settings. Follows the same dune-site plugin pattern as scrollycode and
mermaid extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+224
+11
odoc-interactive-extension/dune-project
··· 1 + (lang dune 3.18) 2 + (using dune_site 0.1) 3 + (name odoc-interactive-extension) 4 + (generate_opam_files true) 5 + 6 + (package 7 + (name odoc-interactive-extension) 8 + (synopsis "Interactive OCaml code cells for odoc documentation") 9 + (depends 10 + (ocaml (>= 4.14)) 11 + odoc))
+25
odoc-interactive-extension/odoc-interactive-extension.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Interactive OCaml code cells for odoc documentation" 4 + depends: [ 5 + "dune" {>= "3.18"} 6 + "ocaml" {>= "4.14"} 7 + "odoc" 8 + ] 9 + build: [ 10 + ["dune" "subst"] {dev} 11 + [ 12 + "dune" 13 + "build" 14 + "-p" 15 + name 16 + "-j" 17 + jobs 18 + "--promote-install-files=false" 19 + "@install" 20 + "@runtest" {with-test} 21 + "@doc" {with-doc} 22 + ] 23 + ["dune" "install" "-p" name "--create-install-files" name] 24 + ] 25 + x-maintenance-intent: ["(latest)"]
+10
odoc-interactive-extension/src/dune
··· 1 + (library 2 + (name interactive_extension) 3 + (public_name odoc-interactive-extension.impl) 4 + (libraries odoc.extension_api)) 5 + 6 + (plugin 7 + (name odoc-interactive-extension) 8 + (package odoc-interactive-extension) 9 + (libraries odoc-interactive-extension.impl) 10 + (site (odoc extensions)))
+178
odoc-interactive-extension/src/interactive_extension.ml
··· 1 + (** Interactive OCaml extension for odoc. 2 + 3 + Provides two extension handlers: 4 + 5 + - {b Code block}: [{@ocaml mode id=name [...]}] code blocks are 6 + transformed to [<x-ocaml>] custom elements with the appropriate 7 + data attributes. 8 + 9 + - {b Tag}: [@x-ocaml.universe], [@x-ocaml.requires], etc. configure 10 + page-level settings emitted as [<meta>] tags in the HTML head. *) 11 + 12 + module Api = Odoc_extension_api 13 + module Block = Api.Block 14 + module Inline = Api.Inline 15 + 16 + (** {1 Page-level configuration} 17 + 18 + Accumulated during tag processing and consumed when emitting 19 + code block resources. *) 20 + 21 + let universe_url = ref None 22 + let requires : string list ref = ref [] 23 + 24 + (** {1 HTML helpers} *) 25 + 26 + let html_escape s = 27 + let buf = Buffer.create (String.length s) in 28 + String.iter (fun c -> 29 + match c with 30 + | '&' -> Buffer.add_string buf "&amp;" 31 + | '<' -> Buffer.add_string buf "&lt;" 32 + | '>' -> Buffer.add_string buf "&gt;" 33 + | '"' -> Buffer.add_string buf "&quot;" 34 + | c -> Buffer.add_char buf c 35 + ) s; 36 + Buffer.contents buf 37 + 38 + (** {1 Config tag handler} *) 39 + 40 + let js_escape s = 41 + let buf = Buffer.create (String.length s) in 42 + String.iter (fun c -> 43 + match c with 44 + | '\'' -> Buffer.add_string buf "\\'" 45 + | '\\' -> Buffer.add_string buf "\\\\" 46 + | c -> Buffer.add_char buf c 47 + ) s; 48 + Buffer.contents buf 49 + 50 + let meta_tag_script name value = 51 + Printf.sprintf 52 + {|(function(){var m=document.createElement('meta');m.name='%s';m.content='%s';document.head.appendChild(m)})();|} 53 + (js_escape name) (js_escape value) 54 + 55 + module X_ocaml_config : Api.Extension = struct 56 + let prefix = "x-ocaml" 57 + 58 + let to_document ~tag content = 59 + let subtag = 60 + match String.split_on_char '.' tag with 61 + | _ :: rest -> String.concat "." rest 62 + | _ -> tag 63 + in 64 + let text = Api.text_of_nestable_block_elements content in 65 + let text = String.trim text in 66 + let resources = match subtag with 67 + | "universe" -> 68 + universe_url := Some text; 69 + [ Api.Js_inline (meta_tag_script "x-ocaml-universe" text) ] 70 + | "requires" -> 71 + let pkgs = 72 + List.filter (fun s -> s <> "") 73 + (List.map String.trim (String.split_on_char ',' text)) 74 + in 75 + requires := pkgs; 76 + [ Api.Js_inline 77 + (meta_tag_script "x-ocaml-packages" (String.concat "," pkgs)) ] 78 + | _ -> [] 79 + in 80 + { Api.content = []; overrides = []; resources; assets = [] } 81 + end 82 + 83 + (** {1 Code block handler} *) 84 + 85 + (** Recognised cell modes — first bare tag matching one of these wins. *) 86 + let mode_tags = [ "interactive"; "exercise"; "test"; "hidden" ] 87 + 88 + module X_ocaml_code : Api.Code_Block_Extension = struct 89 + let prefix = "ocaml" 90 + 91 + let to_document meta code = 92 + let tags = meta.Api.tags in 93 + (* Mode: first bare tag in mode_tags, default "interactive" *) 94 + let mode = 95 + let bare = Api.get_all_tags tags in 96 + match List.find_opt (fun t -> List.mem t mode_tags) bare with 97 + | Some m -> m 98 + | None -> "interactive" 99 + in 100 + let id_attr = Api.get_binding "id" tags in 101 + let for_attr = Api.get_binding "for" tags in 102 + let env_attr = Api.get_binding "env" tags in 103 + let merlin_off = Api.has_tag "no-merlin" tags in 104 + let run_on = Api.get_binding "run-on" tags in 105 + let esc = html_escape in 106 + (* Build attribute string *) 107 + let attrs = 108 + List.filter_map Fun.id [ 109 + Some (Printf.sprintf "mode=\"%s\"" (esc mode)); 110 + Option.map (fun v -> Printf.sprintf "data-id=\"%s\"" (esc v)) id_attr; 111 + Option.map (fun v -> Printf.sprintf "data-for=\"%s\"" (esc v)) for_attr; 112 + Option.map (fun v -> Printf.sprintf "data-env=\"%s\"" (esc v)) env_attr; 113 + (if merlin_off then Some "data-merlin=\"false\"" else None); 114 + Option.map (fun v -> Printf.sprintf "run-on=\"%s\"" (esc v)) run_on; 115 + ] 116 + in 117 + let attr_str = String.concat " " attrs in 118 + let html = 119 + Printf.sprintf "<x-ocaml %s>%s</x-ocaml>" attr_str (html_escape code) 120 + in 121 + let block : Block.t = [{ 122 + attr = [ "x-ocaml-cell" ]; 123 + desc = Raw_markup ("html", html); 124 + }] in 125 + (* Resources: inject the x-ocaml.js script tag with configuration 126 + attributes. The script uses document.currentScript to read 127 + src-worker, backend, etc. so we need a real <script> element 128 + rather than a plain Js_url. We use Js_inline with a guard so 129 + it only executes once (resources are de-duplicated by odoc, but 130 + the guard is belt-and-braces for any edge cases). *) 131 + let base = 132 + match !universe_url with 133 + | Some url -> url 134 + | None -> "./_x-ocaml" 135 + in 136 + let script_loader = Printf.sprintf 137 + {|(function(){if(window.__xOcamlLoaded)return;window.__xOcamlLoaded=true;var s=document.createElement('script');s.src='%s/x-ocaml.js';s.setAttribute('src-worker','%s/worker.js');s.setAttribute('backend','builtin');document.head.appendChild(s)})();|} 138 + (js_escape base) (js_escape base) 139 + in 140 + let resources = [ Api.Js_inline script_loader ] in 141 + Some { Api.content = block; overrides = []; resources; assets = [] } 142 + end 143 + 144 + (** {1 Extension documentation} *) 145 + 146 + let config_info : Api.extension_info = { 147 + info_kind = `Tag; 148 + info_prefix = "x-ocaml"; 149 + info_description = 150 + "Page-level configuration for interactive OCaml cells. \ 151 + Sub-tags: .universe (base URL), .requires (comma-separated packages)."; 152 + info_options = []; 153 + info_example = Some "@x-ocaml.universe ./universe"; 154 + } 155 + 156 + let code_info : Api.extension_info = { 157 + info_kind = `Code_block; 158 + info_prefix = "ocaml"; 159 + info_description = 160 + "Interactive OCaml code cell. Bare tags set the mode \ 161 + (interactive, exercise, test, hidden). Key=value bindings \ 162 + set id, for, env, and run-on attributes."; 163 + info_options = [ 164 + { opt_name = "id"; opt_description = "Cell identifier for test linking"; opt_default = None }; 165 + { opt_name = "for"; opt_description = "Target exercise cell id (for test cells)"; opt_default = None }; 166 + { opt_name = "env"; opt_description = "Environment scope"; opt_default = None }; 167 + { opt_name = "run-on"; opt_description = "When to run: load or click"; opt_default = Some "load" }; 168 + ]; 169 + info_example = Some "{@ocaml exercise id=double[let double x = x * 2]}"; 170 + } 171 + 172 + (** {1 Registration} *) 173 + 174 + let () = 175 + Api.Registry.register (module X_ocaml_config); 176 + Api.Registry.register_code_block (module X_ocaml_code); 177 + Api.Registry.register_extension_info config_info; 178 + Api.Registry.register_extension_info code_info