A graphviz extension for odoc

Initial commit: odoc-dot-extension

Graphviz/DOT diagram support for odoc documentation.
Renders {@dot[...]} code blocks as SVG diagrams using Graphviz.

Extracted from ocaml/odoc repository.

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

+355
+24
dune-project
··· 1 + (lang dune 3.18) 2 + 3 + (using dune_site 0.1) 4 + 5 + (name odoc-dot-extension) 6 + 7 + (source 8 + (github ocaml/odoc-dot-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-dot-extension) 18 + (synopsis "Graphviz/DOT diagram support for odoc documentation") 19 + (description "Renders {@dot[...]} code blocks as SVG diagrams using Graphviz. 20 + Supports width, height, and layout engine options.") 21 + (depends 22 + (ocaml (>= 4.14)) 23 + (dune (>= 3.18)) 24 + odoc))
+29
odoc-dot-extension.opam
··· 1 + opam-version: "2.0" 2 + version: "dev" 3 + synopsis: "Graphviz/DOT diagram support for odoc documentation" 4 + description: """ 5 + Renders {@dot[...]} code blocks as SVG diagrams using Graphviz. 6 + Supports width, height, and layout engine options.""" 7 + maintainer: ["Jon Ludlam <jon@recoil.org>"] 8 + authors: ["Jon Ludlam <jon@recoil.org>"] 9 + license: "ISC" 10 + homepage: "https://github.com/ocaml/odoc-dot-extension" 11 + bug-reports: "https://github.com/ocaml/odoc-dot-extension/issues" 12 + dev-repo: "git+https://github.com/ocaml/odoc-dot-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)"]
+292
src/dot_extension.ml
··· 1 + (** Graphviz/DOT diagram extension for odoc. 2 + 3 + Renders [{@dot[...]}] code blocks as diagrams. By default uses client-side 4 + JavaScript (Viz.js), but can render server-side to PNG/SVG with format option. 5 + 6 + Example: 7 + {[ 8 + {@dot layout=neato[ 9 + digraph G { 10 + a -> b -> c; 11 + b -> d; 12 + } 13 + ]} 14 + ]} 15 + *) 16 + 17 + module Api = Odoc_extension_api 18 + module Block = Odoc_document.Types.Block 19 + module Inline = Odoc_document.Types.Inline 20 + 21 + (** The Viz.js library URL for client-side rendering *) 22 + let viz_js_url = "https://unpkg.com/viz.js@2.1.2/viz.js" 23 + let viz_full_js_url = "https://unpkg.com/viz.js@2.1.2/full.render.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 "dot-diagram-%d" !diagram_counter 31 + 32 + (** Extract option values *) 33 + let get_layout tags = 34 + Api.get_binding "layout" tags 35 + |> Option.value ~default:"dot" 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 + (** Check if content looks like a complete DOT graph *) 49 + let has_graph_wrapper content = 50 + let trimmed = String.trim content in 51 + String.length trimmed > 0 && 52 + (let starts_with prefix s = 53 + String.length s >= String.length prefix && 54 + String.sub s 0 (String.length prefix) = prefix 55 + in 56 + starts_with "digraph" trimmed || 57 + starts_with "graph" trimmed || 58 + starts_with "strict" trimmed) 59 + 60 + (** Wrap content in a digraph if needed *) 61 + let ensure_graph_wrapper content = 62 + if has_graph_wrapper content then content 63 + else Printf.sprintf "digraph G {\n%s\n}" content 64 + 65 + (** Build inline style string from dimensions *) 66 + let make_style width height = 67 + let parts = [] in 68 + let parts = match width with 69 + | Some w -> Printf.sprintf "width: %s" w :: parts 70 + | None -> parts 71 + in 72 + let parts = match height with 73 + | Some h -> Printf.sprintf "height: %s" h :: parts 74 + | None -> parts 75 + in 76 + match parts with 77 + | [] -> "" 78 + | ps -> String.concat "; " (List.rev ps) 79 + 80 + (** Run the dot command to render to a specific format *) 81 + let run_dot ~layout ~format content = 82 + (* Create temp file for input *) 83 + let tmp_in = Filename.temp_file "odoc_dot_" ".dot" in 84 + let tmp_out = Filename.temp_file "odoc_dot_" ("." ^ format) in 85 + Fun.protect ~finally:(fun () -> 86 + (try Sys.remove tmp_in with _ -> ()); 87 + (try Sys.remove tmp_out with _ -> ()) 88 + ) (fun () -> 89 + (* Write DOT content *) 90 + let oc = open_out tmp_in in 91 + output_string oc content; 92 + close_out oc; 93 + (* Run dot command *) 94 + let cmd = Printf.sprintf "dot -K%s -T%s -o %s %s 2>&1" 95 + layout format (Filename.quote tmp_out) (Filename.quote tmp_in) in 96 + let ic = Unix.open_process_in cmd in 97 + let error_output = Buffer.create 256 in 98 + (try 99 + while true do 100 + Buffer.add_string error_output (input_line ic); 101 + Buffer.add_char error_output '\n' 102 + done 103 + with End_of_file -> ()); 104 + let status = Unix.close_process_in ic in 105 + match status with 106 + | Unix.WEXITED 0 -> 107 + (* Read the output file *) 108 + let ic = open_in_bin tmp_out in 109 + let len = in_channel_length ic in 110 + let data = Bytes.create len in 111 + really_input ic data 0 len; 112 + close_in ic; 113 + Ok data 114 + | _ -> 115 + Error (Buffer.contents error_output) 116 + ) 117 + 118 + (** JavaScript code to render a single diagram (for client-side rendering) *) 119 + let render_script id layout content = 120 + Printf.sprintf {| 121 + (function() { 122 + function renderDot() { 123 + var container = document.getElementById('%s'); 124 + if (!container) return; 125 + 126 + if (typeof Viz === 'undefined') { 127 + container.innerHTML = '<pre style="color: red;">Viz.js not loaded</pre>'; 128 + return; 129 + } 130 + 131 + var viz = new Viz(); 132 + viz.renderSVGElement(%S, { engine: %S }) 133 + .then(function(svg) { 134 + container.innerHTML = ''; 135 + container.appendChild(svg); 136 + }) 137 + .catch(function(error) { 138 + container.innerHTML = '<pre style="color: red;">' + error + '</pre>'; 139 + }); 140 + } 141 + 142 + if (document.readyState === 'loading') { 143 + document.addEventListener('DOMContentLoaded', renderDot); 144 + } else { 145 + renderDot(); 146 + } 147 + })(); 148 + |} id content layout 149 + 150 + module Dot_handler : Api.Code_Block_Extension = struct 151 + let prefix = "dot" 152 + 153 + let to_document meta content = 154 + let id = fresh_id () in 155 + let layout = get_layout meta.Api.tags in 156 + let format = get_format meta.Api.tags in 157 + let filename_opt = get_filename meta.Api.tags in 158 + let (width, height) = get_dimensions meta.Api.tags in 159 + let style = make_style width height in 160 + let style_attr = if style = "" then "" else Printf.sprintf " style=\"%s\"" style in 161 + 162 + (* Auto-wrap in digraph if needed *) 163 + let dot_content = ensure_graph_wrapper content in 164 + 165 + match format with 166 + | Some "png" | Some "svg" -> 167 + (* Server-side rendering *) 168 + let fmt = match format with Some f -> f | None -> "png" in 169 + let base_filename = match filename_opt with 170 + | Some f -> f 171 + | None -> Printf.sprintf "dot-%s.%s" id fmt 172 + in 173 + (match run_dot ~layout ~format:fmt dot_content with 174 + | Ok data -> 175 + let html = Printf.sprintf 176 + {|<div id="%s" class="odoc-dot-diagram"%s><img src="%s" alt="DOT diagram" /></div>|} 177 + id style_attr base_filename 178 + in 179 + let block = Block.[{ 180 + attr = ["odoc-dot"]; 181 + desc = Raw_markup ("html", html) 182 + }] in 183 + Some { 184 + Api.content = block; 185 + overrides = []; 186 + resources = []; 187 + assets = [{ Api.asset_filename = base_filename; asset_content = data }]; 188 + } 189 + | Error err -> 190 + (* Show error message *) 191 + let html = Printf.sprintf 192 + "<div id=\"%s\" class=\"odoc-dot-diagram odoc-dot-error\"><pre style=\"color: red;\">Error rendering DOT diagram (is graphviz installed?):\n%s</pre><pre>%s</pre></div>" 193 + id err content 194 + in 195 + let block = Block.[{ 196 + attr = ["odoc-dot"; "odoc-dot-error"]; 197 + desc = Raw_markup ("html", html) 198 + }] in 199 + Some { 200 + Api.content = block; 201 + overrides = []; 202 + resources = []; 203 + assets = []; 204 + }) 205 + 206 + | Some unknown_format -> 207 + (* Unknown format - show error *) 208 + let html = Printf.sprintf 209 + {|<div class="odoc-dot-error"><pre style="color: red;">Unknown format: %s (supported: png, svg)</pre></div>|} 210 + unknown_format 211 + in 212 + let block = Block.[{ 213 + attr = ["odoc-dot-error"]; 214 + desc = Raw_markup ("html", html) 215 + }] in 216 + Some { 217 + Api.content = block; 218 + overrides = []; 219 + resources = []; 220 + assets = []; 221 + } 222 + 223 + | None -> 224 + (* Default: client-side JavaScript rendering *) 225 + let html = Printf.sprintf 226 + {|<div id="%s" class="odoc-dot-diagram"%s><pre>%s</pre></div>|} 227 + id style_attr content 228 + in 229 + let script = render_script id layout dot_content in 230 + let block = Block.[{ 231 + attr = ["odoc-dot"]; 232 + desc = Raw_markup ("html", html) 233 + }] in 234 + Some { 235 + Api.content = block; 236 + overrides = []; 237 + resources = [ 238 + Api.Js_url viz_js_url; 239 + Api.Js_url viz_full_js_url; 240 + Api.Js_inline script; 241 + ]; 242 + assets = []; 243 + } 244 + end 245 + 246 + (** CSS for dot diagrams *) 247 + let dot_css = {| 248 + .odoc-dot-diagram { 249 + margin: 1em 0; 250 + overflow: auto; 251 + } 252 + 253 + .odoc-dot-diagram svg, 254 + .odoc-dot-diagram img { 255 + max-width: 100%; 256 + height: auto; 257 + } 258 + 259 + .odoc-dot-diagram pre { 260 + background: #f5f5f5; 261 + padding: 1em; 262 + border-radius: 4px; 263 + overflow-x: auto; 264 + } 265 + 266 + .odoc-dot-error pre { 267 + color: #c00; 268 + } 269 + |} 270 + 271 + (** Extension documentation *) 272 + let extension_info : Api.extension_info = { 273 + info_kind = `Code_block; 274 + info_prefix = "dot"; 275 + info_description = "Render Graphviz/DOT diagrams. Uses client-side Viz.js by default, or server-side graphviz with format=png|svg."; 276 + info_options = [ 277 + { opt_name = "format"; opt_description = "Output format: png, svg (server-side), or omit for client-side JS"; opt_default = None }; 278 + { opt_name = "layout"; opt_description = "Graphviz layout engine"; opt_default = Some "dot" }; 279 + { opt_name = "width"; opt_description = "CSS width (e.g., 500px, 100%)"; opt_default = None }; 280 + { opt_name = "height"; opt_description = "CSS height"; opt_default = None }; 281 + { opt_name = "filename"; opt_description = "Output filename for server-side rendering"; opt_default = Some "auto-generated" }; 282 + ]; 283 + info_example = Some "{@dot format=png layout=neato[a -> b -> c]}"; 284 + } 285 + 286 + let () = 287 + Api.Registry.register_code_block (module Dot_handler); 288 + Api.Registry.register_extension_info extension_info; 289 + Api.Registry.register_support_file ~prefix:"dot" { 290 + filename = "extensions/dot.css"; 291 + content = dot_css; 292 + }
+10
src/dune
··· 1 + (library 2 + (name dot_extension) 3 + (public_name odoc-dot-extension.impl) 4 + (libraries odoc_extension_api odoc_parser unix)) 5 + 6 + (plugin 7 + (name odoc-dot-extension) 8 + (package odoc-dot-extension) 9 + (libraries odoc-dot-extension.impl) 10 + (site (odoc extensions)))