A mermaid extension for odoc
0
fork

Configure Feed

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

Initial commit: odoc-mermaid-extension

Mermaid diagram support for odoc documentation.
Renders {@mermaid[...]} code blocks as interactive diagrams.

Extracted from ocaml/odoc repository.

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

Jon Ludlam 69ec78b5

+329
+24
dune-project
··· 1 + (lang dune 3.18) 2 + 3 + (using dune_site 0.1) 4 + 5 + (name odoc-mermaid-extension) 6 + 7 + (source 8 + (github ocaml/odoc-mermaid-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-mermaid-extension) 18 + (synopsis "Mermaid diagram support for odoc documentation") 19 + (description "Renders {@mermaid[...]} code blocks as interactive diagrams. 20 + Supports flowcharts, sequence diagrams, class diagrams, and more.") 21 + (depends 22 + (ocaml (>= 4.14)) 23 + (dune (>= 3.18)) 24 + odoc))
+29
odoc-mermaid-extension.opam
··· 1 + opam-version: "2.0" 2 + version: "dev" 3 + synopsis: "Mermaid diagram support for odoc documentation" 4 + description: """ 5 + Renders {@mermaid[...]} code blocks as interactive diagrams. 6 + Supports flowcharts, sequence diagrams, class diagrams, and more.""" 7 + maintainer: ["Jon Ludlam <jon@recoil.org>"] 8 + authors: ["Jon Ludlam <jon@recoil.org>"] 9 + license: "ISC" 10 + homepage: "https://github.com/ocaml/odoc-mermaid-extension" 11 + bug-reports: "https://github.com/ocaml/odoc-mermaid-extension/issues" 12 + dev-repo: "git+https://github.com/ocaml/odoc-mermaid-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 mermaid_extension) 3 + (public_name odoc-mermaid-extension.impl) 4 + (libraries odoc_extension_api odoc_parser unix)) 5 + 6 + (plugin 7 + (name odoc-mermaid-extension) 8 + (package odoc-mermaid-extension) 9 + (libraries odoc-mermaid-extension.impl) 10 + (site (odoc extensions)))
+266
src/mermaid_extension.ml
··· 1 + (** Mermaid diagram extension for odoc. 2 + 3 + Renders [{@mermaid[...]}] code blocks as diagrams. By default uses client-side 4 + JavaScript, but can render server-side to PNG/SVG with format option 5 + (requires mermaid-cli/mmdc). 6 + 7 + Example: 8 + {[ 9 + {@mermaid theme=forest[ 10 + graph LR 11 + A[Start] --> B{Decision} 12 + B -->|Yes| C[OK] 13 + B -->|No| D[Cancel] 14 + ]} 15 + ]} 16 + *) 17 + 18 + module Api = Odoc_extension_api 19 + module Block = Odoc_document.Types.Block 20 + module Inline = Odoc_document.Types.Inline 21 + 22 + (** Mermaid.js CDN URL *) 23 + let mermaid_js_url = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js" 24 + 25 + (** Generate a unique ID for each diagram *) 26 + let diagram_counter = ref 0 27 + 28 + let fresh_id () = 29 + incr diagram_counter; 30 + Printf.sprintf "mermaid-diagram-%d" !diagram_counter 31 + 32 + (** Extract option values *) 33 + let get_theme tags = 34 + Api.get_binding "theme" tags 35 + |> Option.value ~default:"default" 36 + 37 + let get_format tags = 38 + Api.get_binding "format" tags 39 + 40 + let get_filename tags = 41 + Api.get_binding "filename" tags 42 + 43 + let get_dimensions tags = 44 + let width = Api.get_binding "width" tags in 45 + let height = Api.get_binding "height" tags in 46 + (width, height) 47 + 48 + (** Build inline style string from dimensions *) 49 + let make_style width height = 50 + let parts = [] in 51 + let parts = match width with 52 + | Some w -> Printf.sprintf "width: %s" w :: parts 53 + | None -> parts 54 + in 55 + let parts = match height with 56 + | Some h -> Printf.sprintf "height: %s" h :: parts 57 + | None -> parts 58 + in 59 + match parts with 60 + | [] -> "" 61 + | ps -> String.concat "; " (List.rev ps) 62 + 63 + (** HTML-escape content for safe embedding *) 64 + let html_escape s = 65 + let buf = Buffer.create (String.length s) in 66 + String.iter (fun c -> 67 + match c with 68 + | '<' -> Buffer.add_string buf "&lt;" 69 + | '>' -> Buffer.add_string buf "&gt;" 70 + | '&' -> Buffer.add_string buf "&amp;" 71 + | '"' -> Buffer.add_string buf "&quot;" 72 + | c -> Buffer.add_char buf c 73 + ) s; 74 + Buffer.contents buf 75 + 76 + (** Puppeteer config for environments that need --no-sandbox *) 77 + let puppeteer_config = {|{"args": ["--no-sandbox", "--disable-setuid-sandbox"]}|} 78 + 79 + (** Run mmdc (mermaid-cli) to render to a specific format *) 80 + let run_mmdc ~theme ~format content = 81 + let tmp_in = Filename.temp_file "odoc_mermaid_" ".mmd" in 82 + let tmp_out = Filename.temp_file "odoc_mermaid_" ("." ^ format) in 83 + let tmp_config = Filename.temp_file "odoc_mermaid_" ".json" in 84 + Fun.protect ~finally:(fun () -> 85 + (try Sys.remove tmp_in with _ -> ()); 86 + (try Sys.remove tmp_out with _ -> ()); 87 + (try Sys.remove tmp_config with _ -> ()) 88 + ) (fun () -> 89 + (* Write Mermaid content *) 90 + let oc = open_out tmp_in in 91 + output_string oc content; 92 + close_out oc; 93 + (* Write puppeteer config for --no-sandbox *) 94 + let oc = open_out tmp_config in 95 + output_string oc puppeteer_config; 96 + close_out oc; 97 + (* Run mmdc command with puppeteer config *) 98 + let cmd = Printf.sprintf "mmdc -i %s -o %s -t %s -b transparent -p %s 2>&1" 99 + (Filename.quote tmp_in) (Filename.quote tmp_out) theme (Filename.quote tmp_config) in 100 + let ic = Unix.open_process_in cmd in 101 + let error_output = Buffer.create 256 in 102 + (try 103 + while true do 104 + Buffer.add_string error_output (input_line ic); 105 + Buffer.add_char error_output '\n' 106 + done 107 + with End_of_file -> ()); 108 + let status = Unix.close_process_in ic in 109 + match status with 110 + | Unix.WEXITED 0 -> 111 + (* Read the output file *) 112 + let ic = open_in_bin tmp_out in 113 + let len = in_channel_length ic in 114 + let data = Bytes.create len in 115 + really_input ic data 0 len; 116 + close_in ic; 117 + Ok data 118 + | _ -> 119 + Error (Buffer.contents error_output) 120 + ) 121 + 122 + module Mermaid_handler : Api.Code_Block_Extension = struct 123 + let prefix = "mermaid" 124 + 125 + let to_document meta content = 126 + let id = fresh_id () in 127 + let theme = get_theme meta.Api.tags in 128 + let format = get_format meta.Api.tags in 129 + let filename_opt = get_filename meta.Api.tags in 130 + let (width, height) = get_dimensions meta.Api.tags in 131 + let style = make_style width height in 132 + let style_attr = if style = "" then "" else Printf.sprintf " style=\"%s\"" style in 133 + 134 + match format with 135 + | Some "png" | Some "svg" -> 136 + (* Server-side rendering with mmdc *) 137 + let fmt = match format with Some f -> f | None -> "png" in 138 + let base_filename = match filename_opt with 139 + | Some f -> f 140 + | None -> Printf.sprintf "mermaid-%s.%s" id fmt 141 + in 142 + (match run_mmdc ~theme ~format:fmt content with 143 + | Ok data -> 144 + let html = Printf.sprintf 145 + {|<div id="%s" class="odoc-mermaid-diagram"%s><img src="%s" alt="Mermaid diagram" /></div>|} 146 + id style_attr base_filename 147 + in 148 + let block = Block.[{ 149 + attr = ["odoc-mermaid"]; 150 + desc = Raw_markup ("html", html) 151 + }] in 152 + Some { 153 + Api.content = block; 154 + overrides = []; 155 + resources = []; 156 + assets = [{ Api.asset_filename = base_filename; asset_content = data }]; 157 + } 158 + | Error err -> 159 + (* Show error message *) 160 + let html = Printf.sprintf 161 + "<div id=\"%s\" class=\"odoc-mermaid-diagram odoc-mermaid-error\"><pre style=\"color: red;\">Error rendering Mermaid diagram (is mmdc installed?):\n%s</pre><pre>%s</pre></div>" 162 + id err (html_escape content) 163 + in 164 + let block = Block.[{ 165 + attr = ["odoc-mermaid"; "odoc-mermaid-error"]; 166 + desc = Raw_markup ("html", html) 167 + }] in 168 + Some { 169 + Api.content = block; 170 + overrides = []; 171 + resources = []; 172 + assets = []; 173 + }) 174 + 175 + | Some unknown_format -> 176 + let html = Printf.sprintf 177 + {|<div class="odoc-mermaid-error"><pre style="color: red;">Unknown format: %s (supported: png, svg)</pre></div>|} 178 + unknown_format 179 + in 180 + let block = Block.[{ 181 + attr = ["odoc-mermaid-error"]; 182 + desc = Raw_markup ("html", html) 183 + }] in 184 + Some { 185 + Api.content = block; 186 + overrides = []; 187 + resources = []; 188 + assets = []; 189 + } 190 + 191 + | None -> 192 + (* Default: client-side JavaScript rendering *) 193 + let html = Printf.sprintf 194 + {|<div id="%s" class="odoc-mermaid-diagram"%s><pre class="mermaid">%s</pre></div>|} 195 + id style_attr (html_escape content) 196 + in 197 + let init_script = Printf.sprintf {| 198 + if (typeof window.mermaidInitialized === 'undefined') { 199 + window.mermaidInitialized = true; 200 + mermaid.initialize({ 201 + startOnLoad: true, 202 + theme: '%s', 203 + securityLevel: 'loose' 204 + }); 205 + } 206 + |} theme 207 + in 208 + let block = Block.[{ 209 + attr = ["odoc-mermaid"]; 210 + desc = Raw_markup ("html", html) 211 + }] in 212 + Some { 213 + Api.content = block; 214 + overrides = []; 215 + resources = [ 216 + Api.Js_url mermaid_js_url; 217 + Api.Js_inline init_script; 218 + ]; 219 + assets = []; 220 + } 221 + end 222 + 223 + (** CSS for mermaid diagrams *) 224 + let mermaid_css = {| 225 + .odoc-mermaid-diagram { 226 + margin: 1em 0; 227 + overflow: auto; 228 + } 229 + 230 + .odoc-mermaid-diagram svg, 231 + .odoc-mermaid-diagram img { 232 + max-width: 100%; 233 + height: auto; 234 + } 235 + 236 + .odoc-mermaid-diagram pre.mermaid { 237 + background: transparent; 238 + } 239 + 240 + .odoc-mermaid-error pre { 241 + color: #c00; 242 + } 243 + |} 244 + 245 + (** Extension documentation *) 246 + let extension_info : Api.extension_info = { 247 + info_kind = `Code_block; 248 + info_prefix = "mermaid"; 249 + info_description = "Render Mermaid diagrams (flowcharts, sequence diagrams, etc.). Uses client-side JS by default, or server-side mmdc with format=png|svg."; 250 + info_options = [ 251 + { opt_name = "format"; opt_description = "Output format: png, svg (requires mmdc), or omit for client-side JS"; opt_default = None }; 252 + { opt_name = "theme"; opt_description = "Mermaid theme (default, forest, dark, neutral)"; opt_default = Some "default" }; 253 + { opt_name = "width"; opt_description = "CSS width (e.g., 500px, 100%)"; opt_default = None }; 254 + { opt_name = "height"; opt_description = "CSS height"; opt_default = None }; 255 + { opt_name = "filename"; opt_description = "Output filename for server-side rendering"; opt_default = Some "auto-generated" }; 256 + ]; 257 + info_example = Some "{@mermaid format=png theme=forest[graph LR; A-->B]}"; 258 + } 259 + 260 + let () = 261 + Api.Registry.register_code_block (module Mermaid_handler); 262 + Api.Registry.register_extension_info extension_info; 263 + Api.Registry.register_support_file ~prefix:"mermaid" { 264 + filename = "extensions/mermaid.css"; 265 + content = mermaid_css; 266 + }