A graphviz extension for odoc
at main 292 lines 9.0 kB view raw
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 17module Api = Odoc_extension_api 18module Block = Api.Block 19module Inline = Api.Inline 20 21(** The Viz.js library URL for client-side rendering *) 22let viz_js_url = "https://unpkg.com/viz.js@2.1.2/viz.js" 23let viz_full_js_url = "https://unpkg.com/viz.js@2.1.2/full.render.js" 24 25(** Generate a unique ID for each diagram *) 26let diagram_counter = ref 0 27 28let fresh_id () = 29 incr diagram_counter; 30 Printf.sprintf "dot-diagram-%d" !diagram_counter 31 32(** Extract option values *) 33let get_layout tags = 34 Api.get_binding "layout" tags 35 |> Option.value ~default:"dot" 36 37let get_format tags = 38 Api.get_binding "format" tags 39 40let get_filename tags = 41 Api.get_binding "filename" tags 42 43let 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 *) 49let 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 *) 61let 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 *) 66let 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 *) 81let 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) *) 119let 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 150module 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 } 244end 245 246(** CSS for dot diagrams *) 247let 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 *) 272let 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 286let () = 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 = Inline dot_css; 292 }