A graphviz extension for odoc
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 }