A Message Sequence Charts extension for odoc
0
fork

Configure Feed

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

Initial commit: odoc-msc-extension

Message Sequence Chart support for odoc documentation.
Renders {@msc[...]} code blocks as sequence diagrams.

Extracted from ocaml/odoc repository.

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

Jon Ludlam 85a8e9b1

+335
+24
dune-project
··· 1 + (lang dune 3.18) 2 + 3 + (using dune_site 0.1) 4 + 5 + (name odoc-msc-extension) 6 + 7 + (source 8 + (github ocaml/odoc-msc-extension)) 9 + 10 + (license ISC) 11 + 12 + (authors "Jon Ludlam <jon@recoil.org>") 13 + 14 + (maintainers "Jon Ludlam <jon@recoil.org>") 15 + 16 + (package 17 + (name odoc-msc-extension) 18 + (synopsis "Message Sequence Chart support for odoc documentation") 19 + (description "Renders {@msc[...]} code blocks as message sequence charts. 20 + Uses the MscGen syntax for defining sequence diagrams.") 21 + (depends 22 + (ocaml (>= 4.14)) 23 + (dune (>= 3.18)) 24 + odoc))
+29
odoc-msc-extension.opam
··· 1 + opam-version: "2.0" 2 + version: "dev" 3 + synopsis: "Message Sequence Chart support for odoc documentation" 4 + description: """ 5 + Renders {@msc[...]} code blocks as message sequence charts. 6 + Uses the MscGen syntax for defining sequence diagrams.""" 7 + maintainer: ["Jon Ludlam <jon@recoil.org>"] 8 + authors: ["Jon Ludlam <jon@recoil.org>"] 9 + license: "ISC" 10 + homepage: "https://github.com/ocaml/odoc-msc-extension" 11 + bug-reports: "https://github.com/ocaml/odoc-msc-extension/issues" 12 + dev-repo: "git+https://github.com/ocaml/odoc-msc-extension.git" 13 + depends: [ 14 + "dune" {>= "3.18"} 15 + "ocaml" {>= "4.14"} 16 + "odoc" {>= "3.0.0"} 17 + ] 18 + build: [ 19 + [ 20 + "dune" 21 + "build" 22 + "-p" 23 + name 24 + "-j" 25 + jobs 26 + "@install" 27 + ] 28 + ] 29 + x-maintenance-intent: ["(latest)"]
+10
src/dune
··· 1 + (library 2 + (name msc_extension) 3 + (public_name odoc-msc-extension.impl) 4 + (libraries odoc_extension_api odoc_parser unix)) 5 + 6 + (plugin 7 + (name odoc-msc-extension) 8 + (package odoc-msc-extension) 9 + (libraries odoc-msc-extension.impl) 10 + (site (odoc extensions)))
+272
src/msc_extension.ml
··· 1 + (** Message Sequence Chart extension for odoc. 2 + 3 + Renders [{@msc[...]}] code blocks as sequence diagrams. By default uses 4 + client-side JavaScript (mscgen-inpage), but can render server-side to 5 + PNG/SVG with format option (requires mscgen). 6 + 7 + Example: 8 + {[ 9 + {@msc format=png width=600px[ 10 + msc { 11 + a, b, c; 12 + a -> b [label="request"]; 13 + b -> c [label="forward"]; 14 + c -> b [label="response"]; 15 + b -> a [label="reply"]; 16 + } 17 + ]} 18 + ]} 19 + *) 20 + 21 + module Api = Odoc_extension_api 22 + module Block = Odoc_document.Types.Block 23 + module Inline = Odoc_document.Types.Inline 24 + 25 + (** MscGen.js CDN URL - the inpage version auto-renders on DOMContentLoaded *) 26 + let mscgen_js_url = "https://unpkg.com/mscgenjs-inpage@4/dist/mscgen-inpage.js" 27 + 28 + (** Script to load mscgenjs with defer-like behavior *) 29 + let loader_script = Printf.sprintf {| 30 + (function() { 31 + function loadMscgen() { 32 + var script = document.createElement('script'); 33 + script.src = %S; 34 + script.async = false; 35 + document.head.appendChild(script); 36 + } 37 + if (document.readyState === 'loading') { 38 + document.addEventListener('DOMContentLoaded', loadMscgen); 39 + } else { 40 + loadMscgen(); 41 + } 42 + })(); 43 + |} mscgen_js_url 44 + 45 + 46 + (** Generate a unique ID for each diagram *) 47 + let diagram_counter = ref 0 48 + 49 + let fresh_id () = 50 + incr diagram_counter; 51 + Printf.sprintf "msc-diagram-%d" !diagram_counter 52 + 53 + (** Extract option values *) 54 + let get_style tags = 55 + Api.get_binding "named-style" tags 56 + |> Option.value ~default:"basic" 57 + 58 + let get_format tags = 59 + Api.get_binding "format" tags 60 + 61 + let get_filename tags = 62 + Api.get_binding "filename" tags 63 + 64 + (** Extract CSS dimensions *) 65 + let get_dimensions tags = 66 + let width = Api.get_binding "width" tags in 67 + let height = Api.get_binding "height" tags in 68 + (width, height) 69 + 70 + (** Build inline style string from dimensions *) 71 + let make_style width height = 72 + let parts = [] in 73 + let parts = match width with 74 + | Some w -> Printf.sprintf "width: %s" w :: parts 75 + | None -> parts 76 + in 77 + let parts = match height with 78 + | Some h -> Printf.sprintf "height: %s" h :: parts 79 + | None -> parts 80 + in 81 + match parts with 82 + | [] -> "" 83 + | ps -> String.concat "; " (List.rev ps) 84 + 85 + (** HTML-escape content for safe embedding *) 86 + let html_escape s = 87 + let buf = Buffer.create (String.length s) in 88 + String.iter (fun c -> 89 + match c with 90 + | '<' -> Buffer.add_string buf "&lt;" 91 + | '>' -> Buffer.add_string buf "&gt;" 92 + | '&' -> Buffer.add_string buf "&amp;" 93 + | '"' -> Buffer.add_string buf "&quot;" 94 + | c -> Buffer.add_char buf c 95 + ) s; 96 + Buffer.contents buf 97 + 98 + (** Run mscgen to render to a specific format *) 99 + let run_mscgen ~format content = 100 + let tmp_in = Filename.temp_file "odoc_msc_" ".msc" in 101 + let tmp_out = Filename.temp_file "odoc_msc_" ("." ^ format) in 102 + Fun.protect ~finally:(fun () -> 103 + (try Sys.remove tmp_in with _ -> ()); 104 + (try Sys.remove tmp_out with _ -> ()) 105 + ) (fun () -> 106 + (* Write MSC content *) 107 + let oc = open_out tmp_in in 108 + output_string oc content; 109 + close_out oc; 110 + (* Run mscgen command *) 111 + let cmd = Printf.sprintf "mscgen -T %s -i %s -o %s 2>&1" 112 + format (Filename.quote tmp_in) (Filename.quote tmp_out) in 113 + let ic = Unix.open_process_in cmd in 114 + let error_output = Buffer.create 256 in 115 + (try 116 + while true do 117 + Buffer.add_string error_output (input_line ic); 118 + Buffer.add_char error_output '\n' 119 + done 120 + with End_of_file -> ()); 121 + let status = Unix.close_process_in ic in 122 + match status with 123 + | Unix.WEXITED 0 -> 124 + (* Read the output file *) 125 + let ic = open_in_bin tmp_out in 126 + let len = in_channel_length ic in 127 + let data = Bytes.create len in 128 + really_input ic data 0 len; 129 + close_in ic; 130 + Ok data 131 + | _ -> 132 + Error (Buffer.contents error_output) 133 + ) 134 + 135 + module Msc_handler : Api.Code_Block_Extension = struct 136 + let prefix = "msc" 137 + 138 + let to_document meta content = 139 + let id = fresh_id () in 140 + let style_name = get_style meta.Api.tags in 141 + let format = get_format meta.Api.tags in 142 + let filename_opt = get_filename meta.Api.tags in 143 + let (width, height) = get_dimensions meta.Api.tags in 144 + let style = make_style width height in 145 + let style_attr = if style = "" then "" else Printf.sprintf " style=\"%s\"" style in 146 + 147 + match format with 148 + | Some "png" | Some "svg" -> 149 + (* Server-side rendering with mscgen *) 150 + let fmt = match format with Some f -> f | None -> "png" in 151 + let base_filename = match filename_opt with 152 + | Some f -> f 153 + | None -> Printf.sprintf "msc-%s.%s" id fmt 154 + in 155 + (match run_mscgen ~format:fmt content with 156 + | Ok data -> 157 + let html = Printf.sprintf 158 + {|<div id="%s" class="odoc-msc-diagram"%s><img src="%s" alt="MSC diagram" /></div>|} 159 + id style_attr base_filename 160 + in 161 + let block = Block.[{ 162 + attr = ["odoc-msc"]; 163 + desc = Raw_markup ("html", html) 164 + }] in 165 + Some { 166 + Api.content = block; 167 + overrides = []; 168 + resources = []; 169 + assets = [{ Api.asset_filename = base_filename; asset_content = data }]; 170 + } 171 + | Error err -> 172 + (* Show error message *) 173 + let html = Printf.sprintf 174 + "<div id=\"%s\" class=\"odoc-msc-diagram odoc-msc-error\"><pre style=\"color: red;\">Error rendering MSC diagram (is mscgen installed?):\n%s</pre><pre>%s</pre></div>" 175 + id err (html_escape content) 176 + in 177 + let block = Block.[{ 178 + attr = ["odoc-msc"; "odoc-msc-error"]; 179 + desc = Raw_markup ("html", html) 180 + }] in 181 + Some { 182 + Api.content = block; 183 + overrides = []; 184 + resources = []; 185 + assets = []; 186 + }) 187 + 188 + | Some unknown_format -> 189 + let html = Printf.sprintf 190 + {|<div class="odoc-msc-error"><pre style="color: red;">Unknown format: %s (supported: png, svg)</pre></div>|} 191 + unknown_format 192 + in 193 + let block = Block.[{ 194 + attr = ["odoc-msc-error"]; 195 + desc = Raw_markup ("html", html) 196 + }] in 197 + Some { 198 + Api.content = block; 199 + overrides = []; 200 + resources = []; 201 + assets = []; 202 + } 203 + 204 + | None -> 205 + (* Default: client-side JavaScript rendering *) 206 + let data_style = if style_name = "basic" then "" else Printf.sprintf " data-named-style=\"%s\"" style_name in 207 + let html = Printf.sprintf 208 + {|<div id="%s" class="odoc-msc-diagram"%s><script type="text/x-mscgen"%s>%s</script><noscript><pre>%s</pre></noscript></div>|} 209 + id style_attr data_style content (html_escape content) 210 + in 211 + let block = Block.[{ 212 + attr = ["odoc-msc"]; 213 + desc = Raw_markup ("html", html) 214 + }] in 215 + Some { 216 + Api.content = block; 217 + overrides = []; 218 + resources = [ 219 + Api.Js_inline loader_script; 220 + ]; 221 + assets = []; 222 + } 223 + end 224 + 225 + (** CSS for MSC diagrams *) 226 + let msc_css = {| 227 + .odoc-msc-diagram { 228 + margin: 1em 0; 229 + overflow: auto; 230 + } 231 + 232 + .odoc-msc-diagram svg, 233 + .odoc-msc-diagram img { 234 + max-width: 100%; 235 + height: auto; 236 + } 237 + 238 + /* Fallback for noscript */ 239 + .odoc-msc-diagram noscript pre { 240 + background: #f8f8f8; 241 + padding: 1em; 242 + border-radius: 4px; 243 + overflow-x: auto; 244 + } 245 + 246 + .odoc-msc-error pre { 247 + color: #c00; 248 + } 249 + |} 250 + 251 + (** Extension documentation *) 252 + let extension_info : Api.extension_info = { 253 + info_kind = `Code_block; 254 + info_prefix = "msc"; 255 + info_description = "Render Message Sequence Charts. Uses client-side mscgen-inpage.js by default, or server-side mscgen with format=png|svg."; 256 + info_options = [ 257 + { opt_name = "format"; opt_description = "Output format: png, svg (requires mscgen), or omit for client-side JS"; opt_default = None }; 258 + { opt_name = "named-style"; opt_description = "MscGen style (basic, lazy, classic, etc.)"; opt_default = Some "basic" }; 259 + { opt_name = "width"; opt_description = "CSS width (e.g., 500px, 100%)"; opt_default = None }; 260 + { opt_name = "height"; opt_description = "CSS height"; opt_default = None }; 261 + { opt_name = "filename"; opt_description = "Output filename for server-side rendering"; opt_default = Some "auto-generated" }; 262 + ]; 263 + info_example = Some "{@msc format=png[msc { a,b; a->b; }]}"; 264 + } 265 + 266 + let () = 267 + Api.Registry.register_code_block (module Msc_handler); 268 + Api.Registry.register_extension_info extension_info; 269 + Api.Registry.register_support_file ~prefix:"msc" { 270 + filename = "extensions/msc.css"; 271 + content = msc_css; 272 + }