this repo has no description

Initial v2

+995
+28
cors_server.py
··· 1 + #!/usr/bin/env python3 2 + """HTTP server with CORS headers for cross-origin demo testing.""" 3 + import sys 4 + from http.server import HTTPServer, SimpleHTTPRequestHandler 5 + 6 + class CORSHandler(SimpleHTTPRequestHandler): 7 + def end_headers(self): 8 + self.send_header("Access-Control-Allow-Origin", "*") 9 + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") 10 + self.send_header("Access-Control-Allow-Headers", "*") 11 + super().end_headers() 12 + 13 + def do_OPTIONS(self): 14 + self.send_response(200) 15 + self.end_headers() 16 + 17 + def log_message(self, format, *args): 18 + # Suppress request logging to keep test output clean 19 + pass 20 + 21 + if __name__ == "__main__": 22 + port = int(sys.argv[1]) if len(sys.argv) > 1 else 9090 23 + directory = sys.argv[2] if len(sys.argv) > 2 else "." 24 + import os 25 + os.chdir(directory) 26 + server = HTTPServer(("", port), CORSHandler) 27 + print(f"CORS server on http://localhost:{port} serving {directory}") 28 + server.serve_forever()
+231
deploy.sh
··· 1 + #!/bin/bash 2 + # Deploy interactive OCaml demo pages. 3 + # 4 + # This script builds 6 demo pages that showcase in-browser OCaml evaluation 5 + # using the odoc-interactive-extension and js_top_worker (jtw). 6 + # 7 + # ─── One-time setup ────────────────────────────────────────────────────── 8 + # 9 + # This script uses `jtw opam` to build universes from local opam switches. 10 + # In production, `day10 batch --with-jtw` does this at scale inside 11 + # containers (see docs/jtw-admin-guide.md). The manual switch setup 12 + # below is the local-dev path for running these demos. 13 + # 14 + # 1. Create the opam switches. The "default" switch is used for building 15 + # the monorepo and for the yojson 3.x universes. 16 + # 17 + # # default switch (should already exist) 18 + # opam switch create default ocaml-base-compiler.5.4.1 19 + # eval $(opam env --switch default --set-switch) 20 + # opam install yojson 21 + # 22 + # # yojson 2.x switch (for demo2_v2) 23 + # opam switch create demo-yojson-v2 ocaml-base-compiler.5.4.1 24 + # eval $(opam env --switch demo-yojson-v2 --set-switch) 25 + # opam install yojson.2.2.2 26 + # 27 + # # OxCaml switch (for demo3_oxcaml) 28 + # opam switch create 5.2.0+ox \ 29 + # --repos ox=git+https://github.com/oxcaml/opam-repository.git,default 30 + # 31 + # 2. Pin and install js_top_worker packages in every switch that needs a 32 + # universe. From the monorepo root: 33 + # 34 + # for sw in default demo-yojson-v2 5.2.0+ox; do 35 + # eval $(opam env --switch $sw --set-switch) 36 + # opam pin add js_top_worker . --no-action 37 + # opam pin add js_top_worker-web . --no-action 38 + # opam install js_top_worker js_top_worker-web 39 + # done 40 + # 41 + # 3. Build and install the monorepo tools (jtw, odoc, the extension) 42 + # into the default switch: 43 + # 44 + # eval $(opam env --switch default --set-switch) 45 + # dune build @install && dune install 46 + # 47 + # After this, `jtw opam --help` should work. 48 + # 49 + # ─── Production alternative (day10) ───────────────────────────────────── 50 + # 51 + # For building universes at scale (e.g. all of opam), use day10 instead 52 + # of manual switches. day10 runs builds in OCI containers with cached 53 + # overlay layers. See day10/docs/ADMIN_GUIDE.md for full details. 54 + # 55 + # dune exec -- day10 batch \ 56 + # --cache-dir /var/cache/day10 \ 57 + # --opam-repository /var/cache/opam-repository \ 58 + # --local-repo /path/to/js_top_worker \ 59 + # --with-jtw \ 60 + # --jtw-output /var/www/jtw \ 61 + # --html-output /var/www/docs \ 62 + # --with-doc \ 63 + # @packages.json 64 + # 65 + # --local-repo pins js_top_worker packages from a local checkout instead 66 + # of the default remote git repo. day10 discovers *.opam files in that 67 + # directory, bind-mounts it into the container, and uses it for pinning. 68 + # This is the easiest way to test local changes to js_top_worker. 69 + # 70 + # day10 handles switch creation, dependency solving, js_top_worker 71 + # installation, and per-package universe assembly automatically. 72 + # 73 + # ─── Usage ─────────────────────────────────────────────────────────────── 74 + # 75 + # ./deploy.sh # build everything and serve on port 8080 76 + # ./deploy.sh --no-serve # build only, don't start HTTP server 77 + 78 + set -euo pipefail 79 + 80 + MONO=$(cd "$(dirname "$0")/.." && pwd) 81 + DOC_HTML="$MONO/_build/default/_doc/_html/odoc-interactive-extension" 82 + ODOCL="$MONO/_build/default/_doc/_odocl/odoc-interactive-extension" 83 + OPAM_ODOC="$HOME/.opam/default/bin/odoc" 84 + UNIVERSES=$(mktemp -d) 85 + SERVE=true 86 + 87 + if [[ "${1:-}" == "--no-serve" ]]; then 88 + SERVE=false 89 + fi 90 + 91 + echo "=== Step 1: Install odoc extension into opam switch ===" 92 + cd "$MONO" 93 + export OPAMSWITCH=default 94 + eval "$(opam env)" 95 + dune build @install 96 + dune install 2>&1 | tail -5 97 + 98 + echo "" 99 + echo "=== Step 2: Build odoc docs (generates .odocl + base HTML) ===" 100 + # @doc may exit non-zero due to warnings in other packages (e.g. odoc's own 101 + # cheatsheet referencing cmdliner). We tolerate that as long as the odocl 102 + # files we need were actually produced. 103 + dune build @doc 2>&1 | tail -5 || true 104 + if [ ! -f "$ODOCL/page-demo1.odocl" ]; then 105 + echo "ERROR: odocl files not generated — dune build @doc failed." >&2 106 + exit 1 107 + fi 108 + 109 + echo "" 110 + echo "=== Step 3: Regenerate demo HTML with opam odoc (has extension) ===" 111 + # dune's workspace-local odoc can't find dune-site plugins, so we use the 112 + # opam-installed one which has the extension registered. 113 + for page in demo1 demo2_v2 demo2_v3 demo3_oxcaml demo4_crossorigin demo5_multiverse; do 114 + chmod u+w "$DOC_HTML/${page}.html" 2>/dev/null || true 115 + "$OPAM_ODOC" html-generate "$ODOCL/page-${page}.odocl" \ 116 + -o "$DOC_HTML/.." \ 117 + --support-uri=_odoc_support 2>&1 118 + echo " regenerated ${page}.html" 119 + done 120 + 121 + echo "" 122 + echo "=== Step 4: Build x-ocaml.js ===" 123 + dune build x-ocaml/src/x_ocaml.bc.js 124 + 125 + echo "" 126 + echo "=== Step 5: Build universes ===" 127 + 128 + # 5a. Default universe (yojson 3.0 — used by demo1) 129 + echo " building default universe (yojson, default switch)..." 130 + jtw opam --switch=default -o "$UNIVERSES/default" yojson 131 + 132 + # 5b. Yojson v2 universe 133 + echo " building yojson-v2 universe (demo-yojson-v2 switch)..." 134 + jtw opam --switch=demo-yojson-v2 -o "$UNIVERSES/v2" yojson 135 + 136 + # 5c. Yojson v3 universe (same as default, but separate dir for isolation) 137 + echo " building yojson-v3 universe (default switch)..." 138 + jtw opam --switch=default -o "$UNIVERSES/v3" yojson 139 + 140 + # 5d. OxCaml universe (stdlib only) 141 + echo " building oxcaml universe (5.2.0+ox switch)..." 142 + jtw opam --switch=5.2.0+ox -o "$UNIVERSES/oxcaml" 143 + 144 + echo "" 145 + echo "=== Step 6: Deploy assets into doc HTML output ===" 146 + 147 + # _x-ocaml runtime (shared by all pages) 148 + mkdir -p "$DOC_HTML/_x-ocaml" 149 + chmod -R u+w "$DOC_HTML/_x-ocaml" 2>/dev/null || true 150 + cp "$MONO/_build/default/x-ocaml/src/x_ocaml.bc.js" "$DOC_HTML/_x-ocaml/x-ocaml.js" 151 + cp "$UNIVERSES/default/worker.js" "$DOC_HTML/_x-ocaml/worker.js" 152 + echo " deployed _x-ocaml/" 153 + 154 + # Make deployed dirs writable so re-runs can overwrite them. 155 + chmod -R u+w "$DOC_HTML/universe" "$DOC_HTML/universe-v2" \ 156 + "$DOC_HTML/universe-v3" "$DOC_HTML/universe-oxcaml" 2>/dev/null || true 157 + 158 + # demo1: ./universe 159 + rm -rf "$DOC_HTML/universe" 160 + cp -r "$UNIVERSES/default" "$DOC_HTML/universe" 161 + echo " deployed universe/ (demo1)" 162 + 163 + # demo2_v2: ./universe-v2 164 + rm -rf "$DOC_HTML/universe-v2" 165 + cp -r "$UNIVERSES/v2" "$DOC_HTML/universe-v2" 166 + echo " deployed universe-v2/ (demo2_v2)" 167 + 168 + # demo2_v3: ./universe-v3 169 + rm -rf "$DOC_HTML/universe-v3" 170 + cp -r "$UNIVERSES/v3" "$DOC_HTML/universe-v3" 171 + echo " deployed universe-v3/ (demo2_v3)" 172 + 173 + # demo3_oxcaml: ./universe-oxcaml 174 + rm -rf "$DOC_HTML/universe-oxcaml" 175 + cp -r "$UNIVERSES/oxcaml" "$DOC_HTML/universe-oxcaml" 176 + echo " deployed universe-oxcaml/ (demo3_oxcaml)" 177 + 178 + # Copy x-ocaml.js into each universe (for cross-origin blob: fallback) 179 + for d in universe universe-v2 universe-v3 universe-oxcaml; do 180 + cp "$DOC_HTML/_x-ocaml/x-ocaml.js" "$DOC_HTML/$d/x-ocaml.js" 181 + done 182 + 183 + # Cross-origin universe (same content as default, served on port 9090) 184 + CROSSORIGIN_DIR="$DOC_HTML/../_crossorigin_universes" 185 + chmod -R u+w "$CROSSORIGIN_DIR" 2>/dev/null || true 186 + rm -rf "$CROSSORIGIN_DIR" 187 + mkdir -p "$CROSSORIGIN_DIR" 188 + cp -r "$DOC_HTML/universe" "$CROSSORIGIN_DIR/universe" 189 + echo " deployed _crossorigin_universes/ (for port 9090)" 190 + 191 + # Multiverse (per-package layout with universe linking) 192 + echo "" 193 + echo "=== Step 7: Build multiverse (per-package layout) ===" 194 + MULTIVERSE_DIR="$DOC_HTML/../_multiverse" 195 + chmod -R u+w "$MULTIVERSE_DIR" 2>/dev/null || true 196 + rm -rf "$MULTIVERSE_DIR" 197 + jtw opam-all --switch=default yojson -o "$MULTIVERSE_DIR" 198 + cp "$MONO/_build/default/x-ocaml/src/x_ocaml.bc.js" "$MULTIVERSE_DIR/x-ocaml.js" 199 + echo " deployed _multiverse/ (for port 9090)" 200 + 201 + echo "" 202 + echo "=== Done ===" 203 + echo "Demo pages at: $DOC_HTML/" 204 + echo "" 205 + echo " demo1.html — basic OCaml + yojson (default switch)" 206 + echo " demo2_v2.html — yojson 2.2.2 (demo-yojson-v2 switch)" 207 + echo " demo2_v3.html — yojson 3.0.0 (default switch)" 208 + echo " demo3_oxcaml.html — OxCaml extensions (5.2.0+ox switch)" 209 + echo " demo4_crossorigin.html — cross-origin loading (needs port 9090)" 210 + echo " demo5_multiverse.html — multiverse per-package layout (needs port 9090)" 211 + echo "" 212 + echo "Cross-origin demos (demo4, demo5) require a CORS-enabled server on port 9090." 213 + echo "They serve different directories, so you can only test one at a time:" 214 + echo "" 215 + echo " demo4 (cross-origin):" 216 + echo " python3 $MONO/odoc-interactive-extension/cors_server.py 9090 $DOC_HTML/../_crossorigin_universes" 217 + echo "" 218 + echo " demo5 (multiverse):" 219 + echo " python3 $MONO/odoc-interactive-extension/cors_server.py 9090 $DOC_HTML/../_multiverse" 220 + 221 + # Clean up 222 + rm -rf "$UNIVERSES" 223 + 224 + if $SERVE; then 225 + echo "" 226 + echo "Starting HTTP server on http://localhost:8080" 227 + echo "Visit: http://localhost:8080/odoc-interactive-extension/demo1.html" 228 + echo "" 229 + cd "$DOC_HTML/.." 230 + exec python3 -m http.server 8080 231 + fi
+39
doc/demo1.mld
··· 1 + {0 Interactive OCaml Demo} 2 + 3 + @x-ocaml.universe ./universe 4 + @x-ocaml.worker ./universe/worker.js 5 + 6 + This page demonstrates interactive OCaml code cells powered by 7 + [x-ocaml] and [js_top_worker]. 8 + 9 + {1 Basic Expressions} 10 + 11 + Try evaluating some OCaml expressions: 12 + 13 + {@ocaml[ 14 + 1 + 2 * 3 15 + ]} 16 + 17 + {@ocaml[ 18 + let greet name = Printf.sprintf "Hello, %s!" name 19 + 20 + let () = print_endline (greet "World") 21 + ]} 22 + 23 + {1 Using Yojson} 24 + 25 + These cells use the [yojson] library loaded from the universe: 26 + 27 + {@ocaml[ 28 + #require "yojson" 29 + ]} 30 + 31 + {@ocaml[ 32 + let json = `Assoc [ 33 + ("name", `String "OCaml"); 34 + ("version", `Float 5.4); 35 + ("features", `List [`String "modules"; `String "types"]) 36 + ] 37 + 38 + let () = print_endline (Yojson.Safe.pretty_to_string json) 39 + ]}
+24
doc/demo2_v2.mld
··· 1 + {0 Yojson v2 Demo} 2 + 3 + @x-ocaml.universe ./universe-v2 4 + @x-ocaml.worker ./universe-v2/worker.js 5 + 6 + This page uses {b yojson 2.2.2} from a separate universe directory. 7 + 8 + {@ocaml[ 9 + #require "yojson" 10 + ]} 11 + 12 + {@ocaml[ 13 + (* Yojson 2.x API *) 14 + let json = `Assoc [("key", `String "value")] 15 + let s = Yojson.Safe.to_string json 16 + let () = print_endline s 17 + ]} 18 + 19 + {@ocaml[ 20 + (* Yojson 2.x: Yojson.Safe.prettify is a string->string function *) 21 + let ugly = Yojson.Safe.to_string (`Assoc [("compact", `Bool true); ("data", `List [`Int 1; `Int 2; `Int 3])]) 22 + let pretty = Yojson.Safe.prettify ugly 23 + let () = print_endline pretty 24 + ]}
+24
doc/demo2_v3.mld
··· 1 + {0 Yojson v3 Demo} 2 + 3 + @x-ocaml.universe ./universe-v3 4 + @x-ocaml.worker ./universe-v3/worker.js 5 + 6 + This page uses {b yojson 3.0.0} from a separate universe directory. 7 + 8 + {@ocaml[ 9 + #require "yojson" 10 + ]} 11 + 12 + {@ocaml[ 13 + (* Yojson 3.0 API *) 14 + let json = `Assoc [("key", `String "value")] 15 + let s = Yojson.Safe.to_string json 16 + let () = print_endline s 17 + ]} 18 + 19 + {@ocaml[ 20 + (* Build and query JSON *) 21 + let parsed = `Assoc [("x", `Int 42); ("y", `String "hello")] 22 + let x = Yojson.Safe.Util.member "x" parsed 23 + let () = Printf.printf "x = %s\n" (Yojson.Safe.to_string x) 24 + ]}
+84
doc/demo3_oxcaml.mld
··· 1 + {0 OxCaml Interactive Demo} 2 + 3 + @x-ocaml.universe ./universe-oxcaml 4 + @x-ocaml.worker ./universe-oxcaml/worker.js 5 + 6 + This page demonstrates OxCaml language extensions running interactively 7 + in the browser via [x-ocaml] and [js_top_worker]. 8 + 9 + {1 List Comprehensions} 10 + 11 + OxCaml adds Python/Haskell-style list and array comprehensions: 12 + 13 + {@ocaml[ 14 + let squares = [ x * x for x = 1 to 10 ] 15 + 16 + let () = List.iter (fun x -> Printf.printf "%d " x) squares 17 + ]} 18 + 19 + {@ocaml[ 20 + let evens = [ x for x = 1 to 20 when x mod 2 = 0 ] 21 + 22 + let () = Printf.printf "Evens: %s\n" 23 + (String.concat ", " (List.map string_of_int evens)) 24 + ]} 25 + 26 + Nested comprehensions produce the cartesian product: 27 + 28 + {@ocaml[ 29 + let pairs = [ (x, y) for x = 1 to 3 for y = 1 to 3 when x <> y ] 30 + 31 + let () = List.iter (fun (x, y) -> Printf.printf "(%d,%d) " x y) pairs 32 + ]} 33 + 34 + {1 Array Comprehensions} 35 + 36 + Array comprehensions create arrays using the same syntax as list comprehensions: 37 + 38 + {@ocaml[ 39 + let squares = [| x * x for x = 1 to 10 |] 40 + 41 + let () = Array.iter (fun x -> Printf.printf "%d " x) squares 42 + ]} 43 + 44 + {@ocaml[ 45 + let fibs = 46 + let a = Array.make 10 0 in 47 + a.(0) <- 1; a.(1) <- 1; 48 + for i = 2 to 9 do a.(i) <- a.(i-1) + a.(i-2) done; 49 + [| a.(i) for i = 0 to 9 |] 50 + 51 + let () = Array.iter (fun x -> Printf.printf "%d " x) fibs 52 + ]} 53 + 54 + {1 Let Mutable} 55 + 56 + [let mutable] provides mutable local variables without heap allocation: 57 + 58 + {@ocaml[ 59 + let triangle n = 60 + let mutable total = 0 in 61 + for i = 1 to n do 62 + total <- total + i 63 + done; 64 + total 65 + 66 + let () = Printf.printf "triangle 10 = %d\n" (triangle 10) 67 + ]} 68 + 69 + {@ocaml[ 70 + let fizzbuzz n = 71 + let mutable result = [] in 72 + for i = n downto 1 do 73 + let s = match i mod 3, i mod 5 with 74 + | 0, 0 -> "FizzBuzz" 75 + | 0, _ -> "Fizz" 76 + | _, 0 -> "Buzz" 77 + | _ -> string_of_int i 78 + in 79 + result <- s :: result 80 + done; 81 + result 82 + 83 + let () = print_endline (String.concat " " (fizzbuzz 15)) 84 + ]}
+36
doc/demo4_crossorigin.mld
··· 1 + {0 Cross-Origin Demo} 2 + 3 + @x-ocaml.universe http://localhost:9090/universe 4 + @x-ocaml.worker http://localhost:9090/universe/worker.js 5 + 6 + This page demonstrates {b cross-origin} loading of OCaml universes. 7 + The page is served from [localhost:8080] while the worker and libraries 8 + are loaded from [localhost:9090], exercising the blob: URL worker 9 + creation and sync XHR + eval library loading code paths. 10 + 11 + {1 Basic Expression} 12 + 13 + {@ocaml[ 14 + 1 + 2 * 3 15 + ]} 16 + 17 + {@ocaml[ 18 + let greet name = Printf.sprintf "Hello, %s!" name 19 + 20 + let () = print_endline (greet "Cross-Origin World") 21 + ]} 22 + 23 + {1 Loading a Library} 24 + 25 + {@ocaml[ 26 + #require "yojson" 27 + ]} 28 + 29 + {@ocaml[ 30 + let json = `Assoc [ 31 + ("origin", `String "cross-origin"); 32 + ("port", `Int 9090) 33 + ] 34 + 35 + let () = print_endline (Yojson.Safe.pretty_to_string json) 36 + ]}
+46
doc/demo5_multiverse.mld
··· 1 + {0 Multiverse Demo} 2 + 3 + @x-ocaml.universe http://localhost:9090/yojson 4 + @x-ocaml.worker http://localhost:9090/worker.js 5 + 6 + This page demonstrates a {b multiverse} layout where each package is 7 + built and hosted independently. The universe URL points at 8 + [localhost:9090/yojson], which contains only yojson's own artifacts. 9 + Stdlib is discovered automatically via the ["universes"] link in 10 + yojson's [findlib_index.json]: 11 + 12 + {v 13 + yojson/findlib_index.json: 14 + {"meta_files": ["lib/yojson/META"], "universes": ["../stdlib"]} 15 + v} 16 + 17 + This is how a large-scale host like [ocaml.org] would serve packages: 18 + each package is a small self-contained directory, with links to its 19 + dependencies. 20 + 21 + {1 Basic Expression} 22 + 23 + {@ocaml[ 24 + 1 + 2 * 3 25 + ]} 26 + 27 + {@ocaml[ 28 + let greet name = Printf.sprintf "Hello, %s!" name 29 + 30 + let () = print_endline (greet "Multiverse World") 31 + ]} 32 + 33 + {1 Loading a Library} 34 + 35 + {@ocaml[ 36 + #require "yojson" 37 + ]} 38 + 39 + {@ocaml[ 40 + let json = `Assoc [ 41 + ("source", `String "multiverse"); 42 + ("linked_universes", `Int 2) 43 + ] 44 + 45 + let () = print_endline (Yojson.Safe.pretty_to_string json) 46 + ]}
+2
doc/dune
··· 1 + (documentation 2 + (package odoc-interactive-extension))
+11
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.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
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)))
+185
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 worker_url = ref None 23 + let requires : string list ref = ref [] 24 + 25 + (** {1 HTML helpers} *) 26 + 27 + let html_escape s = 28 + let buf = Buffer.create (String.length s) in 29 + String.iter (fun c -> 30 + match c with 31 + | '&' -> Buffer.add_string buf "&amp;" 32 + | '<' -> Buffer.add_string buf "&lt;" 33 + | '>' -> Buffer.add_string buf "&gt;" 34 + | '"' -> Buffer.add_string buf "&quot;" 35 + | c -> Buffer.add_char buf c 36 + ) s; 37 + Buffer.contents buf 38 + 39 + (** {1 Config tag handler} *) 40 + 41 + let js_escape s = 42 + let buf = Buffer.create (String.length s) in 43 + String.iter (fun c -> 44 + match c with 45 + | '\'' -> Buffer.add_string buf "\\'" 46 + | '\\' -> Buffer.add_string buf "\\\\" 47 + | c -> Buffer.add_char buf c 48 + ) s; 49 + Buffer.contents buf 50 + 51 + let meta_tag_script name value = 52 + Printf.sprintf 53 + {|(function(){var m=document.createElement('meta');m.name='%s';m.content='%s';document.head.appendChild(m)})();|} 54 + (js_escape name) (js_escape value) 55 + 56 + module X_ocaml_config : Api.Extension = struct 57 + let prefix = "x-ocaml" 58 + 59 + let to_document ~tag content = 60 + let subtag = 61 + match String.split_on_char '.' tag with 62 + | _ :: rest -> String.concat "." rest 63 + | _ -> tag 64 + in 65 + let text = Api.text_of_nestable_block_elements content in 66 + let text = String.trim text in 67 + let resources = match subtag with 68 + | "universe" -> 69 + universe_url := Some text; 70 + [ Api.Js_inline (meta_tag_script "x-ocaml-universe" text) ] 71 + | "worker" -> 72 + worker_url := Some text; 73 + [ Api.Js_inline (meta_tag_script "x-ocaml-worker" text) ] 74 + | "requires" -> 75 + let pkgs = 76 + List.filter (fun s -> s <> "") 77 + (List.map String.trim (String.split_on_char ',' text)) 78 + in 79 + requires := pkgs; 80 + [ Api.Js_inline 81 + (meta_tag_script "x-ocaml-packages" (String.concat "," pkgs)) ] 82 + | _ -> [] 83 + in 84 + { Api.content = []; overrides = []; resources; assets = [] } 85 + end 86 + 87 + (** {1 Code block handler} *) 88 + 89 + (** Recognised cell modes — first bare tag matching one of these wins. *) 90 + let mode_tags = [ "interactive"; "exercise"; "test"; "hidden" ] 91 + 92 + module X_ocaml_code : Api.Code_Block_Extension = struct 93 + let prefix = "ocaml" 94 + 95 + let to_document meta code = 96 + let tags = meta.Api.tags in 97 + (* Mode: first bare tag in mode_tags, default "interactive" *) 98 + let mode = 99 + let bare = Api.get_all_tags tags in 100 + match List.find_opt (fun t -> List.mem t mode_tags) bare with 101 + | Some m -> m 102 + | None -> "interactive" 103 + in 104 + let id_attr = Api.get_binding "id" tags in 105 + let for_attr = Api.get_binding "for" tags in 106 + let env_attr = Api.get_binding "env" tags in 107 + let merlin_off = Api.has_tag "no-merlin" tags in 108 + let run_on = Api.get_binding "run-on" tags in 109 + let esc = html_escape in 110 + (* Build attribute string *) 111 + let attrs = 112 + List.filter_map Fun.id [ 113 + Some (Printf.sprintf "mode=\"%s\"" (esc mode)); 114 + Option.map (fun v -> Printf.sprintf "data-id=\"%s\"" (esc v)) id_attr; 115 + Option.map (fun v -> Printf.sprintf "data-for=\"%s\"" (esc v)) for_attr; 116 + Option.map (fun v -> Printf.sprintf "data-env=\"%s\"" (esc v)) env_attr; 117 + (if merlin_off then Some "data-merlin=\"false\"" else None); 118 + Option.map (fun v -> Printf.sprintf "run-on=\"%s\"" (esc v)) run_on; 119 + ] 120 + in 121 + let attr_str = String.concat " " attrs in 122 + let html = 123 + Printf.sprintf "<x-ocaml %s>%s</x-ocaml>" attr_str (html_escape code) 124 + in 125 + let block : Block.t = [{ 126 + attr = [ "x-ocaml-cell" ]; 127 + desc = Raw_markup ("html", html); 128 + }] in 129 + (* Resources: inject the x-ocaml.js script tag with configuration 130 + attributes. The script uses document.currentScript to read 131 + src-worker, backend, etc. so we need a real <script> element 132 + rather than a plain Js_url. We use Js_inline with a guard so 133 + it only executes once (resources are de-duplicated by odoc, but 134 + the guard is belt-and-braces for any edge cases). 135 + 136 + x-ocaml.js and worker.js are always loaded from a local path 137 + (same origin as the page) to avoid cross-origin Worker restrictions. 138 + The universe URL (which may be cross-origin) is communicated via 139 + the <meta name="x-ocaml-universe"> tag, which x-ocaml.js reads 140 + to configure findlib_index fetching. *) 141 + let local_base = "./_x-ocaml" in 142 + let backend = match !universe_url with Some _ -> "jtw" | None -> "builtin" in 143 + let script_loader = Printf.sprintf 144 + {|(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','%s');document.head.appendChild(s)})();|} 145 + (js_escape local_base) (js_escape local_base) (js_escape backend) 146 + in 147 + let resources = [ Api.Js_inline script_loader ] in 148 + Some { Api.content = block; overrides = []; resources; assets = [] } 149 + end 150 + 151 + (** {1 Extension documentation} *) 152 + 153 + let config_info : Api.extension_info = { 154 + info_kind = `Tag; 155 + info_prefix = "x-ocaml"; 156 + info_description = 157 + "Page-level configuration for interactive OCaml cells. \ 158 + Sub-tags: .universe (base URL), .requires (comma-separated packages)."; 159 + info_options = []; 160 + info_example = Some "@x-ocaml.universe ./universe"; 161 + } 162 + 163 + let code_info : Api.extension_info = { 164 + info_kind = `Code_block; 165 + info_prefix = "ocaml"; 166 + info_description = 167 + "Interactive OCaml code cell. Bare tags set the mode \ 168 + (interactive, exercise, test, hidden). Key=value bindings \ 169 + set id, for, env, and run-on attributes."; 170 + info_options = [ 171 + { opt_name = "id"; opt_description = "Cell identifier for test linking"; opt_default = None }; 172 + { opt_name = "for"; opt_description = "Target exercise cell id (for test cells)"; opt_default = None }; 173 + { opt_name = "env"; opt_description = "Environment scope"; opt_default = None }; 174 + { opt_name = "run-on"; opt_description = "When to run: load or click"; opt_default = Some "load" }; 175 + ]; 176 + info_example = Some "{@ocaml exercise id=double[let double x = x * 2]}"; 177 + } 178 + 179 + (** {1 Registration} *) 180 + 181 + let () = 182 + Api.Registry.register (module X_ocaml_config); 183 + Api.Registry.register_code_block (module X_ocaml_code); 184 + Api.Registry.register_extension_info config_info; 185 + Api.Registry.register_extension_info code_info
+82
test_crossorigin.js
··· 1 + // test_crossorigin.js — Playwright test for cross-origin demo 2 + const { chromium } = require('playwright'); 3 + 4 + (async () => { 5 + const browser = await chromium.launch(); 6 + const page = await browser.newPage(); 7 + 8 + const consoleMessages = []; 9 + page.on('console', msg => consoleMessages.push(msg.text())); 10 + 11 + const errors = []; 12 + page.on('pageerror', err => errors.push(err.message)); 13 + 14 + console.log('Navigating to demo4_crossorigin.html...'); 15 + await page.goto('http://localhost:8080/odoc-interactive-extension/demo4_crossorigin.html'); 16 + 17 + // Wait for cells to render and execute (worker init + eval) 18 + console.log('Waiting for cells to execute...'); 19 + await page.waitForTimeout(15000); 20 + 21 + // Check for cross-origin indicators in console 22 + const hasCrossOriginWorker = consoleMessages.some(m => 23 + m.includes('9090')); 24 + const hasCrossOriginFetch = consoleMessages.some(m => 25 + m.includes('Cross-origin import via fetch+eval')); 26 + const hasInitFinished = consoleMessages.some(m => 27 + m.includes('init() finished')); 28 + const hasSetupFinished = consoleMessages.some(m => 29 + m.includes('setup() finished')); 30 + 31 + // Check for init_error 32 + const hasInitError = consoleMessages.some(m => 33 + m.includes('init_error')); 34 + 35 + // Check for correct output in page (x-ocaml uses Shadow DOM, so we 36 + // look at worker output messages in the console instead) 37 + const hasInt7 = consoleMessages.some(m => 38 + m.includes('int = 7')); 39 + const hasGreeting = consoleMessages.some(m => 40 + m.includes('Hello, Cross-Origin World')); 41 + 42 + console.log(''); 43 + console.log('=== Results ==='); 44 + console.log(` Worker loaded from port 9090: ${hasCrossOriginWorker ? 'YES' : 'NO'}`); 45 + console.log(` Cross-origin fetch+eval used: ${hasCrossOriginFetch ? 'YES' : 'NO'}`); 46 + console.log(` Init completed: ${hasInitFinished ? 'YES' : 'NO'}`); 47 + console.log(` Setup completed: ${hasSetupFinished ? 'YES' : 'NO'}`); 48 + console.log(` Init error: ${hasInitError ? 'YES (BAD)' : 'NO (good)'}`); 49 + console.log(` Output "int = 7": ${hasInt7 ? 'YES' : 'NO'}`); 50 + console.log(` Output "Hello, Cross-Origin": ${hasGreeting ? 'YES' : 'NO'}`); 51 + 52 + let passed = true; 53 + 54 + if (!hasInitFinished) { 55 + console.log('\nFAIL: Worker init did not complete'); 56 + passed = false; 57 + } 58 + if (!hasSetupFinished) { 59 + console.log('\nFAIL: Worker setup did not complete'); 60 + passed = false; 61 + } 62 + if (hasInitError) { 63 + console.log('\nFAIL: Worker reported init_error'); 64 + const errMsg = consoleMessages.find(m => m.includes('init_error')); 65 + console.log(` ${errMsg}`); 66 + passed = false; 67 + } 68 + if (!hasInt7 || !hasGreeting) { 69 + console.log('\nFAIL: Expected output not found in page'); 70 + passed = false; 71 + } 72 + 73 + if (passed) { 74 + console.log('\nPASS: Cross-origin demo working correctly'); 75 + } else { 76 + console.log('\n--- Console messages (last 40) ---'); 77 + consoleMessages.slice(-40).forEach(m => console.log(` ${m.substring(0, 150)}`)); 78 + } 79 + 80 + await browser.close(); 81 + process.exit(passed ? 0 : 1); 82 + })();
+54
test_crossorigin.sh
··· 1 + #!/bin/bash 2 + # Test cross-origin demo end-to-end. 3 + # Starts page server (8080) and CORS universe server (9090), 4 + # then runs Playwright to verify cross-origin loading works. 5 + set -euo pipefail 6 + 7 + SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) 8 + MONO=$(cd "$SCRIPT_DIR/.." && pwd) 9 + DOC_HTML="$MONO/_build/default/_doc/_html/odoc-interactive-extension" 10 + CROSSORIGIN_DIR="$MONO/_build/default/_doc/_html/_crossorigin_universes" 11 + 12 + cleanup() { 13 + [[ -n "${PAGE_PID:-}" ]] && kill "$PAGE_PID" 2>/dev/null || true 14 + [[ -n "${CORS_PID:-}" ]] && kill "$CORS_PID" 2>/dev/null || true 15 + wait 2>/dev/null || true 16 + } 17 + trap cleanup EXIT 18 + 19 + echo "=== Building demos ===" 20 + bash "$SCRIPT_DIR/deploy.sh" --no-serve 21 + 22 + echo "" 23 + echo "=== Starting servers ===" 24 + 25 + # Page server on port 8080 26 + cd "$DOC_HTML/.." 27 + python3 -m http.server 8080 &>/dev/null & 28 + PAGE_PID=$! 29 + 30 + # CORS universe server on port 9090 31 + python3 "$SCRIPT_DIR/cors_server.py" 9090 "$CROSSORIGIN_DIR" &>/dev/null & 32 + CORS_PID=$! 33 + 34 + sleep 2 35 + echo " Page server: http://localhost:8080 (PID $PAGE_PID)" 36 + echo " CORS server: http://localhost:9090 (PID $CORS_PID)" 37 + 38 + # Verify both servers respond 39 + curl -sf http://localhost:8080/odoc-interactive-extension/demo4_crossorigin.html > /dev/null \ 40 + || { echo "FAIL: page server not responding"; exit 1; } 41 + curl -sf http://localhost:9090/universe/findlib_index.json > /dev/null \ 42 + || { echo "FAIL: CORS server not responding"; exit 1; } 43 + 44 + # Verify CORS headers 45 + CORS_HEADER=$(curl -sI http://localhost:9090/universe/findlib_index.json | grep -i 'access-control-allow-origin' || true) 46 + if [[ -z "$CORS_HEADER" ]]; then 47 + echo "FAIL: CORS server missing Access-Control-Allow-Origin header" 48 + exit 1 49 + fi 50 + echo " CORS header: $CORS_HEADER" 51 + 52 + echo "" 53 + echo "=== Running Playwright test ===" 54 + NODE_PATH="$MONO/x-ocaml/test/node_modules" node "$SCRIPT_DIR/test_crossorigin.js"
+114
test_multiverse.js
··· 1 + // test_multiverse.js — Playwright test for multiverse demo (per-package layout) 2 + const { chromium } = require('playwright'); 3 + 4 + (async () => { 5 + const browser = await chromium.launch(); 6 + const page = await browser.newPage(); 7 + 8 + const consoleMessages = []; 9 + page.on('console', msg => consoleMessages.push(msg.text())); 10 + 11 + const errors = []; 12 + page.on('pageerror', err => errors.push(err.message)); 13 + 14 + console.log('Navigating to demo5_multiverse.html...'); 15 + await page.goto('http://localhost:8080/odoc-interactive-extension/demo5_multiverse.html'); 16 + 17 + // Wait for cells to render and execute (worker init + universe linking + eval) 18 + console.log('Waiting for cells to execute...'); 19 + await page.waitForTimeout(15000); 20 + 21 + // Check multiverse-specific behavior in console 22 + const hasWorkerFrom9090 = consoleMessages.some(m => 23 + m.includes('9090')); 24 + const hasStdlibFallback = consoleMessages.some(m => 25 + m.includes('stdlib_dcs not found') && m.includes('findlib discovery')); 26 + const hasStdlibDiscovered = consoleMessages.some(m => 27 + m.includes('Found stdlib dcs via findlib')); 28 + const hasDcsRewrite = consoleMessages.some(m => 29 + m.includes('Rewriting dcs_url')); 30 + const hasUniverseLinking = consoleMessages.some(m => 31 + m.includes('universes')); 32 + const hasInitFinished = consoleMessages.some(m => 33 + m.includes('init() finished')); 34 + const hasSetupFinished = consoleMessages.some(m => 35 + m.includes('setup() finished')); 36 + const hasInitError = consoleMessages.some(m => 37 + m.includes('init_error')); 38 + 39 + // Check for correct outputs 40 + const hasInt7 = consoleMessages.some(m => 41 + m.includes('int = 7')); 42 + const hasGreeting = consoleMessages.some(m => 43 + m.includes('Hello, Multiverse World')); 44 + const hasYojsonLoaded = consoleMessages.some(m => 45 + m.includes('Custom #require: yojson loaded')); 46 + const hasJsonOutput = consoleMessages.some(m => 47 + m.includes('"source": "multiverse"') || m.includes('multiverse')); 48 + 49 + console.log(''); 50 + console.log('=== Results ==='); 51 + console.log(` Worker loaded from port 9090: ${hasWorkerFrom9090 ? 'YES' : 'NO'}`); 52 + console.log(` stdlib_dcs fallback triggered: ${hasStdlibFallback ? 'YES' : 'NO'}`); 53 + console.log(` Stdlib discovered via findlib: ${hasStdlibDiscovered ? 'YES' : 'NO'}`); 54 + console.log(` dcs_url rewritten to absolute: ${hasDcsRewrite ? 'YES' : 'NO'}`); 55 + console.log(` Universe linking used: ${hasUniverseLinking ? 'YES' : 'NO'}`); 56 + console.log(` Init completed: ${hasInitFinished ? 'YES' : 'NO'}`); 57 + console.log(` Setup completed: ${hasSetupFinished ? 'YES' : 'NO'}`); 58 + console.log(` Init error: ${hasInitError ? 'YES (BAD)' : 'NO (good)'}`); 59 + console.log(` Output "int = 7": ${hasInt7 ? 'YES' : 'NO'}`); 60 + console.log(` Output "Hello, Multiverse": ${hasGreeting ? 'YES' : 'NO'}`); 61 + console.log(` Yojson loaded via #require: ${hasYojsonLoaded ? 'YES' : 'NO'}`); 62 + console.log(` JSON output with "multiverse": ${hasJsonOutput ? 'YES' : 'NO'}`); 63 + 64 + let passed = true; 65 + 66 + if (!hasStdlibFallback) { 67 + console.log('\nFAIL: stdlib_dcs fallback was not triggered (multiverse needs it)'); 68 + passed = false; 69 + } 70 + if (!hasStdlibDiscovered) { 71 + console.log('\nFAIL: Stdlib was not discovered via findlib universe linking'); 72 + passed = false; 73 + } 74 + if (!hasDcsRewrite) { 75 + console.log('\nFAIL: dcs_url was not rewritten to absolute URL'); 76 + passed = false; 77 + } 78 + if (!hasInitFinished) { 79 + console.log('\nFAIL: Worker init did not complete'); 80 + passed = false; 81 + } 82 + if (!hasSetupFinished) { 83 + console.log('\nFAIL: Worker setup did not complete'); 84 + passed = false; 85 + } 86 + if (hasInitError) { 87 + console.log('\nFAIL: Worker reported init_error'); 88 + const errMsg = consoleMessages.find(m => m.includes('init_error')); 89 + console.log(` ${errMsg}`); 90 + passed = false; 91 + } 92 + if (!hasInt7) { 93 + console.log('\nFAIL: Expected output "int = 7" not found'); 94 + passed = false; 95 + } 96 + if (!hasGreeting) { 97 + console.log('\nFAIL: Expected output "Hello, Multiverse World" not found'); 98 + passed = false; 99 + } 100 + if (!hasYojsonLoaded) { 101 + console.log('\nFAIL: Yojson was not loaded via #require'); 102 + passed = false; 103 + } 104 + 105 + if (passed) { 106 + console.log('\nPASS: Multiverse demo working correctly'); 107 + } else { 108 + console.log('\n--- Console messages (last 50) ---'); 109 + consoleMessages.slice(-50).forEach(m => console.log(` ${m.substring(0, 200)}`)); 110 + } 111 + 112 + await browser.close(); 113 + process.exit(passed ? 0 : 1); 114 + })();