···1+MIT License
2+3+Copyright (c) 2025 Arthur Wendling, Tarides
4+5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
+44
x-ocaml/README.md
···00000000000000000000000000000000000000000000
···1+Embed OCaml notebooks in any web page thanks to WebComponents! Just copy and paste the following script in your html page source to load the integration:
2+3+```html
4+<script async
5+ src="https://cdn.jsdelivr.net/gh/art-w/x-ocaml.js@6/x-ocaml.js"
6+ src-worker="https://cdn.jsdelivr.net/gh/art-w/x-ocaml.js@6/x-ocaml.worker+effects.js"
7+ integrity="sha256-3ITn2LRgP/8Rz6oqP5ZQTysesNaSi6/iEdbDvBfyCSE="
8+ crossorigin="anonymous"
9+></script>
10+```
11+12+This will introduce a new html tag `<x-ocaml>` to present OCaml code, for example:
13+14+```html
15+<x-ocaml>let x = 42</x-ocaml>
16+```
17+18+The script will initialize a CodeMirror editor integrated with the OCaml interpreter, Merlin and OCamlformat (all running in a web worker). [**Check out the online demo**](https://art-w.github.io/x-ocaml/) for more details, including how to load additional OCaml libraries and ppx in your page.
19+20+For an even easier integration, @patricoferris made a command-line tool [`xocmd`](https://github.com/patricoferris/xocmd) to convert markdown files to use `<x-ocaml>`!
21+22+## Compilation
23+24+To avoid relying on a public CDN and host your own copy of the `x-ocaml` scripts, you can reproduce the javascript files with:
25+26+```shell
27+$ git clone --recursive https://github.com/art-w/x-ocaml
28+$ cd x-ocaml
29+30+# Install the dependencies with either dune:
31+x-ocaml/ $ dune pkg lock
32+# Or with opam:
33+x-ocaml/ $ opam update && opam install . --deps-only
34+35+# Make sure to use the release profile to optimize the js file size
36+x-ocaml/ $ dune build --profile=release
37+38+x-ocaml/ $ ls *.js
39+x-ocaml.js x-ocaml.worker+effects.js x-ocaml.worker.js
40+```
41+42+## Acknowledgments
43+44+This project was heavily inspired by the amazing [`sketch.sh`](https://sketch.sh), [@jonludlam's notebooks in Odoc](https://jon.recoil.org/notebooks/foundations/foundations1.html#a-first-session-with-ocaml), [`blogaml` by @panglesd](https://github.com/panglesd/blogaml), and all the wonderful people who made [Try OCaml](https://try.ocamlpro.com/) and other online playgrounds! It was made possible thanks to the invaluable [`js_of_ocaml-toplevel`](https://github.com/ocsigen/js_of_ocaml) library, the magical [`merlin-js` by @voodoos](https://github.com/voodoos/merlin-js), the excellent [CodeMirror bindings by @patricoferris](https://github.com/patricoferris/jsoo-code-mirror/), the guidance of @Julow on `ocamlformat` and the javascript expertise of @xvw.
···1+let mapper _argv =
2+ let module Current_ast = Ppxlib_ast.Selected_ast in
3+ let structure s =
4+ match s with [] -> [] | _ -> Ppxlib.Driver.map_structure s
5+ in
6+ let structure _ st =
7+ Current_ast.of_ocaml Structure st
8+ |> structure
9+ |> Current_ast.to_ocaml Structure
10+ in
11+ let signature _ si =
12+ Current_ast.of_ocaml Signature si
13+ |> Ppxlib.Driver.map_signature
14+ |> Current_ast.to_ocaml Signature
15+ in
16+ { Ast_mapper.default_mapper with structure; signature }
17+18+let () = Ast_mapper.register "ppxlib" mapper
···1+module Merlin_protocol = Protocol
2+3+type id = int
4+5+type request =
6+ | Merlin of id * Merlin_protocol.action
7+ | Eval of id * int * string
8+ | Format of id * string
9+ | Format_config of string
10+ | Setup
11+12+type output =
13+ | Stdout of string
14+ | Stderr of string
15+ | Meta of string
16+ | Html of string
17+18+type response =
19+ | Merlin_response of id * Merlin_protocol.answer
20+ | Top_response of id * output list
21+ | Top_response_at of id * int * output list
22+ | Formatted_source of id * string
23+24+let req_to_bytes (req : request) = Marshal.to_bytes req []
25+let resp_to_bytes (req : response) = Marshal.to_bytes req []
26+let req_of_bytes req : request = Marshal.from_bytes req 0
27+let resp_of_string resp : response = Marshal.from_string resp 0
···1+open Brr
2+3+type status = Not_run | Running | Run_ok | Request_run
4+5+type t = {
6+ id : int;
7+ mutable prev : t option;
8+ mutable next : t option;
9+ mutable status : status;
10+ cm : Editor.t;
11+ worker : Client.t;
12+ merlin_worker : Merlin_ext.Client.worker;
13+ run_on : [ `Click | `Load ];
14+}
15+16+let id t = t.id
17+18+let pre_source t =
19+ let rec go acc t =
20+ match t.prev with
21+ | None -> String.concat "\n" acc
22+ | Some e -> go (Editor.source e.cm :: acc) e
23+ in
24+ let s = go [] t in
25+ if s = "" then s else s ^ " ;;\n"
26+27+let rec invalidate_from ~editor =
28+ editor.status <- Not_run;
29+ Editor.clear editor.cm;
30+ let count = Editor.nb_lines editor.cm in
31+ match editor.next with
32+ | None -> ()
33+ | Some editor ->
34+ Editor.set_previous_lines editor.cm count;
35+ invalidate_from ~editor
36+37+let invalidate_after ~editor =
38+ editor.status <- Not_run;
39+ let count = Editor.nb_lines editor.cm in
40+ match editor.next with
41+ | None -> ()
42+ | Some editor ->
43+ Editor.set_previous_lines editor.cm count;
44+ invalidate_from ~editor
45+46+let rec refresh_lines_from ~editor =
47+ let count = Editor.nb_lines editor.cm in
48+ match editor.next with
49+ | None -> ()
50+ | Some editor ->
51+ Editor.set_previous_lines editor.cm count;
52+ refresh_lines_from ~editor
53+54+let rec run editor =
55+ if editor.status = Running then ()
56+ else (
57+ editor.status <- Request_run;
58+ Editor.clear_messages editor.cm;
59+ match editor.prev with
60+ | Some e when e.status <> Run_ok -> run e
61+ | _ ->
62+ editor.status <- Running;
63+ let code_txt = Editor.source editor.cm in
64+ let line_number = 1 + Editor.get_previous_lines editor.cm in
65+ Client.eval ~id:editor.id ~line_number editor.worker code_txt)
66+67+let set_prev ~prev t =
68+ let () = match t.prev with None -> () | Some prev -> prev.next <- None in
69+ t.prev <- prev;
70+ match prev with
71+ | None ->
72+ Editor.set_previous_lines t.cm 0;
73+ refresh_lines_from ~editor:t
74+ | Some p ->
75+ assert (p.next = None);
76+ p.next <- Some t;
77+ refresh_lines_from ~editor:p
78+79+let set_source_from_html editor this =
80+ let doc = Webcomponent.text_content this in
81+ let doc = String.trim doc in
82+ Editor.set_source editor.cm doc;
83+ invalidate_from ~editor;
84+ Client.fmt ~id:editor.id editor.worker doc
85+86+let init_css shadow ~extra_style ~inline_style =
87+ El.append_children shadow
88+ [
89+ El.style
90+ (El.txt (Jstr.of_string [%blob "style.css"])
91+ ::
92+ (match inline_style with
93+ | None -> []
94+ | Some inline_style ->
95+ [
96+ El.txt
97+ @@ Jstr.of_string (":host{" ^ Jstr.to_string inline_style ^ "}");
98+ ]));
99+ ];
100+ match extra_style with
101+ | None -> ()
102+ | Some src_style ->
103+ El.append_children shadow
104+ [
105+ El.link
106+ ~at:
107+ [
108+ At.href src_style;
109+ At.rel (Jstr.of_string "stylesheet");
110+ At.type' (Jstr.of_string "text/css");
111+ ]
112+ ();
113+ ]
114+115+let init ~id ~run_on ?extra_style ?inline_style worker this =
116+ let shadow = Webcomponent.attach_shadow this in
117+ init_css shadow ~extra_style ~inline_style;
118+119+ let run_btn = El.button [ El.txt (Jstr.of_string "Run") ] in
120+ El.append_children shadow
121+ [ El.div ~at:[ At.class' (Jstr.of_string "run_btn") ] [ run_btn ] ];
122+123+ let cm = Editor.make shadow in
124+125+ let merlin = Merlin_ext.make ~id worker in
126+ let merlin_worker = Merlin_ext.Client.make_worker merlin in
127+ let editor =
128+ {
129+ id;
130+ status = Not_run;
131+ cm;
132+ prev = None;
133+ next = None;
134+ worker;
135+ merlin_worker;
136+ run_on;
137+ }
138+ in
139+ Editor.on_change cm (fun () -> invalidate_after ~editor);
140+ set_source_from_html editor this;
141+142+ Merlin_ext.set_context merlin (fun () -> pre_source editor);
143+ Editor.configure_merlin cm (fun () -> Merlin_ext.extensions merlin_worker);
144+145+ let () =
146+ Mutation_observer.observe ~target:(Webcomponent.as_target this)
147+ @@ Mutation_observer.create (fun _ _ -> set_source_from_html editor this)
148+ in
149+150+ let _ : Ev.listener =
151+ Ev.listen Ev.click (fun _ev -> run editor) (El.as_target run_btn)
152+ in
153+154+ editor
155+156+let set_source editor doc =
157+ Editor.set_source editor.cm doc;
158+ refresh_lines_from ~editor
159+160+let render_message msg =
161+ let raw_html s =
162+ let el = El.div [] in
163+ let el_t = El.to_jv el in
164+ Jv.set el_t "innerHTML" (Jv.of_jstr @@ Jstr.of_string s);
165+ el
166+ in
167+ let kind, text =
168+ match msg with
169+ | X_protocol.Stdout str -> ("stdout", El.txt' str)
170+ | Stderr str -> ("stderr", El.txt' str)
171+ | Meta str -> ("meta", El.txt' str)
172+ | Html str -> ("html", raw_html str)
173+ in
174+ El.pre ~at:[ At.class' (Jstr.of_string ("caml_" ^ kind)) ] [ text ]
175+176+let add_message t loc msg =
177+ Editor.add_message t.cm loc (List.map render_message msg)
178+179+let completed_run ed msg =
180+ (if msg <> [] then
181+ let loc = String.length (Editor.source ed.cm) in
182+ add_message ed loc msg);
183+ ed.status <- Run_ok;
184+ match ed.next with Some e when e.status = Request_run -> run e | _ -> ()
185+186+let receive_merlin t msg =
187+ Merlin_ext.Client.on_message t.merlin_worker
188+ (Merlin_ext.fix_answer ~pre:(pre_source t) ~doc:(Editor.source t.cm) msg)
189+190+let loadable t = t.run_on = `Load
+19
x-ocaml/src/cell.mli
···0000000000000000000
···1+type t
2+3+val init :
4+ id:int ->
5+ run_on:[ `Click | `Load ] ->
6+ ?extra_style:Jstr.t ->
7+ ?inline_style:Jstr.t ->
8+ Client.t ->
9+ Webcomponent.t ->
10+ t
11+12+val id : t -> int
13+val set_source : t -> string -> unit
14+val add_message : t -> int -> X_protocol.output list -> unit
15+val completed_run : t -> X_protocol.output list -> unit
16+val set_prev : prev:t option -> t -> unit
17+val receive_merlin : t -> Protocol.answer -> unit
18+val loadable : t -> bool
19+val run : t -> unit
···1+module Worker = Brr_webworkers.Worker
2+open Brr
3+4+type t = Worker.t
5+6+let current_url =
7+ let url = Window.location G.window in
8+ let path = Jstr.to_string (Uri.path url) in
9+ let url =
10+ match List.rev (String.split_on_char '/' path) with
11+ | [] | "" :: _ -> url
12+ | _ :: rev_path -> (
13+ let path = Jstr.of_string @@ String.concat "/" @@ List.rev rev_path in
14+ match Uri.with_uri ~path ~query:Jstr.empty ~fragment:Jstr.empty url with
15+ | Ok url -> url
16+ | Error _ -> url)
17+ in
18+ Jstr.to_string (Uri.to_jstr url)
19+20+let absolute_url url =
21+ if
22+ not
23+ (String.starts_with ~prefix:"http:" url
24+ || String.starts_with ~prefix:"https:" url)
25+ then current_url ^ url
26+ else url
27+28+let wrap_url ?extra_load url =
29+ let url = absolute_url url in
30+ let extra =
31+ match extra_load with
32+ | None -> ""
33+ | Some extra -> "','" ^ absolute_url extra
34+ in
35+ let script = "importScripts('" ^ url ^ extra ^ "');" in
36+ let script = Jstr.of_string script in
37+ let url =
38+ match Base64.(encode (data_of_binary_jstr script)) with
39+ | Ok data -> Jstr.to_string data
40+ | Error _ -> assert false
41+ in
42+ "data:text/javascript;base64," ^ url
43+44+let make ?extra_load url =
45+ Worker.create @@ Jstr.of_string @@ wrap_url ?extra_load url
46+47+let on_message t fn =
48+ let fn m =
49+ let m = Ev.as_type m in
50+ let msg = Bytes.to_string @@ Brr_io.Message.Ev.data m in
51+ fn (X_protocol.resp_of_string msg)
52+ in
53+ let _listener =
54+ Ev.listen Brr_io.Message.Ev.message fn @@ Worker.as_target t
55+ in
56+ ()
57+58+let post worker msg = Worker.post worker (X_protocol.req_to_bytes msg)
59+60+let eval ~id ~line_number worker code =
61+ post worker (Eval (id, line_number, code))
62+63+let fmt ~id worker code = post worker (Format (id, code))
+7
x-ocaml/src/client.mli
···0000000
···1+type t
2+3+val make : ?extra_load:string -> string -> t
4+val on_message : t -> (X_protocol.response -> unit) -> unit
5+val post : t -> X_protocol.request -> unit
6+val eval : id:int -> line_number:int -> t -> string -> unit
7+val fmt : id:int -> t -> string -> unit
···1+type t = {
2+ view : Code_mirror.Editor.View.t;
3+ messages_comp : Code_mirror.Compartment.t;
4+ lines_comp : Code_mirror.Compartment.t;
5+ merlin_comp : Code_mirror.Compartment.t;
6+ mutable merlin_extension : unit -> Code_mirror.Extension.t list;
7+ changes : Code_mirror.Compartment.t;
8+ mutable previous_lines : int;
9+ mutable current_doc : string;
10+ mutable messages : (int * Brr.El.t list) list;
11+}
12+13+let find_line_ends at doc =
14+ let rec go i =
15+ if i >= String.length doc || doc.[i] = '\n' then i else go (i + 1)
16+ in
17+ go at
18+19+let render_messages cm =
20+ let open Code_mirror.Editor in
21+ let open Code_mirror.Decoration in
22+ let (State.Facet ((module F), it)) = View.decorations () in
23+ let doc = cm.current_doc in
24+ let ranges =
25+ Array.of_list
26+ @@ List.map (fun (at, msg) ->
27+ range ~from:at ~to_:at
28+ @@ widget ~block:true ~side:99
29+ @@ Widget.make (fun () -> msg))
30+ @@ List.filter (fun (at, _) -> at <= String.length doc)
31+ @@ List.map (fun (at, msg) ->
32+ let at = find_line_ends at doc in
33+ (at, msg))
34+ @@ List.concat
35+ @@ List.map (fun (loc, lst) -> List.map (fun m -> (loc, m)) lst)
36+ @@ List.sort (fun (a, _) (b, _) -> Int.compare a b) cm.messages
37+ in
38+ F.of_ it (Range_set.of' ranges)
39+40+let refresh_messages ed =
41+ Code_mirror.Editor.View.dispatch ed.view
42+ (Code_mirror.Compartment.reconfigure ed.messages_comp
43+ [ render_messages ed ])
44+45+let custom_ln editor =
46+ Code_mirror.Editor.View.line_numbers (fun x ->
47+ string_of_int (editor.previous_lines + x))
48+49+let refresh_lines ed =
50+ Code_mirror.Editor.View.dispatch ed.view
51+ @@ Code_mirror.Compartment.reconfigure ed.lines_comp [ custom_ln ed ]
52+53+let refresh_merlin ed =
54+ Code_mirror.Editor.View.dispatch ed.view
55+ @@ Code_mirror.Compartment.reconfigure ed.merlin_comp (ed.merlin_extension ())
56+57+let configure_merlin ed extension =
58+ ed.merlin_extension <- extension;
59+ refresh_merlin ed
60+61+let clear x =
62+ x.messages <- [];
63+ refresh_lines x;
64+ refresh_messages x;
65+ refresh_merlin x
66+67+let source_of_state s =
68+ String.concat "\n" @@ Array.to_list @@ Array.map Jstr.to_string
69+ @@ Code_mirror.Text.to_jstr_array
70+ @@ Code_mirror.Editor.State.doc s
71+72+let source t = source_of_state @@ Code_mirror.Editor.View.state t.view
73+74+let prefix_length a b =
75+ let rec go i =
76+ if i >= String.length a || i >= String.length b || a.[i] <> b.[i] then i
77+ else go (i + 1)
78+ in
79+ go 0
80+81+let basic_setup =
82+ Jv.get Jv.global "__CM__basic_setup" |> Code_mirror.Extension.of_jv
83+84+let make parent =
85+ let open Code_mirror.Editor in
86+ let changes = Code_mirror.Compartment.make () in
87+ let messages = Code_mirror.Compartment.make () in
88+ let lines = Code_mirror.Compartment.make () in
89+ let merlin = Code_mirror.Compartment.make () in
90+ let extensions =
91+ [|
92+ basic_setup;
93+ Code_mirror.Editor.View.line_wrapping ();
94+ Code_mirror.Compartment.of' lines [];
95+ Code_mirror.Compartment.of' messages [];
96+ Code_mirror.Compartment.of' changes [];
97+ Code_mirror.Compartment.of' merlin [];
98+ |]
99+ in
100+ let config = State.Config.create ~doc:Jstr.empty ~extensions () in
101+ let state = State.create ~config () in
102+ let opts = View.opts ~state ~parent () in
103+ let view = View.create ~opts () in
104+ {
105+ previous_lines = 0;
106+ current_doc = "";
107+ messages = [];
108+ view;
109+ messages_comp = messages;
110+ lines_comp = lines;
111+ merlin_comp = merlin;
112+ merlin_extension = (fun () -> []);
113+ changes;
114+ }
115+116+let set_current_doc t new_doc =
117+ let at = prefix_length t.current_doc new_doc in
118+ t.current_doc <- new_doc;
119+ t.messages <- List.filter (fun (loc, _) -> loc < at) t.messages;
120+ refresh_messages t
121+122+let on_change cm fn =
123+ let has_changed =
124+ let open Code_mirror.Editor in
125+ let (State.Facet ((module F), it)) = View.update_listener () in
126+ F.of_ it (fun ev ->
127+ if View.Update.doc_changed ev then
128+ let new_doc = source_of_state (View.Update.state ev) in
129+ if not (String.equal cm.current_doc new_doc) then (
130+ set_current_doc cm new_doc;
131+ fn ()))
132+ in
133+ Code_mirror.Editor.View.dispatch cm.view
134+ @@ Code_mirror.Compartment.reconfigure cm.changes [ has_changed ]
135+136+let count_lines str =
137+ if str = "" then 0
138+ else
139+ let nb = ref 1 in
140+ for i = 0 to String.length str - 1 do
141+ if str.[i] = '\n' then incr nb
142+ done;
143+ !nb
144+145+let nb_lines t = t.previous_lines + count_lines t.current_doc
146+let get_previous_lines t = t.previous_lines
147+148+let set_previous_lines t nb =
149+ t.previous_lines <- nb;
150+ refresh_lines t
151+152+let set_messages t msg =
153+ t.messages <- msg;
154+ refresh_messages t
155+156+let clear_messages t = set_messages t []
157+let add_message t loc msg = set_messages t ((loc, msg) :: t.messages)
158+159+let set_source t doc =
160+ set_current_doc t doc;
161+ Code_mirror.Editor.View.set_doc t.view (Jstr.of_string doc)
+13
x-ocaml/src/editor.mli
···0000000000000
···1+type t
2+3+val make : Brr.El.t -> t
4+val source : t -> string
5+val set_source : t -> string -> unit
6+val clear : t -> unit
7+val nb_lines : t -> int
8+val get_previous_lines : t -> int
9+val set_previous_lines : t -> int -> unit
10+val clear_messages : t -> unit
11+val add_message : t -> int -> Brr.El.t list -> unit
12+val on_change : t -> (unit -> unit) -> unit
13+val configure_merlin : t -> (unit -> Code_mirror.Extension.t list) -> unit
···1+let id = ref (0, 0)
2+3+let output_html m =
4+ let id, loc = !id in
5+ Js_of_ocaml.Worker.post_message
6+ (X_protocol.resp_to_bytes
7+ (X_protocol.Top_response_at (id, loc, [ Html m ])));
8+ ()
+5
x-ocaml/worker-lib/x_ocaml_lib.mli
···00000
···1+val output_html : string -> unit
2+3+(**/**)
4+5+val id : (int * int) ref