Source file odoc_extension_api.ml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
(** Odoc Extension API
This module provides the interface for odoc tag extensions.
Extensions are dynamically loaded plugins that handle custom tags
like [@note], [@rfc], [@example], etc.
*)
(** {1 Re-exported Types}
These are the odoc types that extensions need to work with.
*)
module Location_ = Odoc_model.Location_
module Block = Odoc_document.Types.Block
module Inline = Odoc_document.Types.Inline
module Description = Odoc_document.Types.Description
module Url = Odoc_document.Url
module Target = Odoc_document.Types.Target
(** {1 Extension Types} *)
(** Resources that can be injected into the HTML [<head>].
See {!Odoc_extension_registry} for full documentation on execution
timing, SPA deduplication semantics, and guidance for extension
authors.
{b Summary:}
{ul
{- [Js_url] / [Css_url] — deduplicated by resolved URL. Loaded at
most once across SPA navigations.}
{- [Js_inline] — stamped with [data-spa-inline] at generation time.
Executed {b exactly once}: on the first SPA navigation that
introduces the script. Do not use [DOMContentLoaded] inside
these; use a [MutationObserver] if you need to react to content
changes on subsequent navigations.}
{- [Css_inline] — injected on every navigation (CSS is additive and
idempotent).}} *)
type resource = Odoc_extension_registry.resource =
| Js_url of string
(** External JavaScript: emitted as [<script src="…">].
Deduplicated by absolute URL on SPA navigation. *)
| Css_url of string
(** External CSS: emitted as [<link rel="stylesheet" href="…">].
Deduplicated by absolute URL on SPA navigation. *)
| Js_inline of string
(** Inline JavaScript: emitted as [<script data-spa-inline="…">…</script>].
Runs once on first encounter; skipped on subsequent SPA navigations
that carry an identical script. *)
| Css_inline of string
(** Inline CSS: emitted as [<style>…</style>].
Re-injected on each SPA navigation (idempotent). *)
(** Binary asset generated by an extension.
Assets are written alongside the HTML output. To reference an asset
in your content, use the placeholder [__ODOC_ASSET__filename__] which
will be replaced with the correct relative path during HTML generation. *)
type asset = Odoc_extension_registry.asset = {
asset_filename : string; (** Filename for the asset, e.g., "diagram-1.png" *)
asset_content : bytes; (** Binary content *)
}
(** {1 Extension Documentation}
Extensions can register documentation describing their options and usage.
This information is displayed by [odoc extensions --help]. *)
(** Documentation for a single option *)
type option_doc = Odoc_extension_registry.option_doc = {
opt_name : string; (** Option name, e.g., "width" *)
opt_description : string; (** What the option does *)
opt_default : string option; (** Default value if any *)
}
(** Documentation/metadata for an extension *)
type extension_info = Odoc_extension_registry.extension_info = {
info_kind : [ `Tag | `Code_block ]; (** Type of extension *)
info_prefix : string; (** The prefix this extension handles *)
info_description : string; (** Short description *)
info_options : option_doc list; (** Supported options *)
info_example : string option; (** Example usage *)
}
(** Output from the document phase *)
type extension_output = {
content : Block.t;
(** Universal content - used by all backends unless overridden *)
overrides : (string * string) list;
(** Backend-specific raw content overrides.
E.g., [("html", "<div>...</div>"); ("markdown", "...")] *)
resources : resource list;
(** Page-level resources (JS/CSS). Only used by HTML backend.
See {!resource} for execution and deduplication semantics,
especially in SPA (single-page app) shells. *)
assets : asset list;
(** Binary assets to write alongside HTML output.
Reference in content using [__ODOC_ASSET__filename__] placeholder. *)
}
(** Raised when an extension receives a tag variant it doesn't support *)
exception Unsupported_tag of string
(** {1 Link Environment}
Extensions that need to look up other pages or modules during linking
can use the cross-reference environment. *)
module Env = Odoc_xref2.Env
(** {1 Extension Interface} *)
(** The signature that all tag extensions must implement *)
module type Extension = sig
val prefix : string
(** The tag prefix this extension handles.
E.g., "note" handles [@note], "admonition" handles [@admonition.note] *)
val to_document :
tag:string ->
Comment.nestable_block_element Location_.with_location list ->
extension_output
(** Document phase: convert tag to renderable content.
Called during document generation. Returns content plus any
page-level resources needed (JS/CSS). *)
end
(** Extensions that also need link-time access to the cross-reference
environment implement this extended signature. *)
module type Extension_with_link = sig
include Extension
val link :
tag:string ->
Env.t ->
Comment.nestable_block_element Location_.with_location list ->
Comment.nestable_block_element Location_.with_location list
(** Link phase: transform tag content with access to the cross-reference
environment. Called during linking, after references have been resolved.
Use [Env.lookup_page_by_name] etc. to look up other pages. *)
end
(** {1 Code Block Extensions}
Extensions can also handle code blocks like [{@dot[...]}] or
[{@mermaid[...]}]. These extensions receive the language tag,
metadata (key=value pairs), and the code content.
*)
(** Metadata for code blocks *)
type code_block_meta = Odoc_extension_registry.code_block_meta = {
language : string;
(** The language tag, e.g., "dot" or "mermaid" *)
tags : Odoc_parser.Ast.code_block_tag list;
(** Additional metadata tags like [width=500] or [format=svg].
Each tag is either [`Tag name] for bare tags or
[`Binding (key, value)] for key=value pairs. *)
}
(** The signature that code block extensions must implement *)
module type Code_Block_Extension = sig
val prefix : string
(** The language prefix this extension handles.
E.g., "dot" handles [{@dot[...]}], "mermaid" handles [{@mermaid[...]}] *)
val to_document :
code_block_meta ->
string ->
extension_output option
(** Transform a code block. Takes metadata and code content.
Returns [Some output] to replace the code block, or [None] to
fall back to default rendering.
Example metadata for [{\@dot width=500 format=svg[digraph \{...\}]}]:
- [meta.language = "dot"]
- [meta.tags = [`Binding ("width", "500"); `Binding ("format", "svg")]]
- content = "digraph \{...\}" *)
end
(** {1 Support Files}
Extensions can register support files (CSS, JS, images, etc.) that
will be output by [odoc support-files].
*)
type support_file_content = Odoc_extension_registry.support_file_content =
| Inline of string
| Copy_from of string
type support_file = Odoc_extension_registry.support_file = {
filename : string; (** Relative path, e.g., "extensions/admonition.css" *)
content : support_file_content;
}
(** {1 Extension Registry}
Extensions register themselves here when loaded.
odoc queries the registry when processing custom tags.
*)
module Registry = struct
let register (module E : Extension) =
let handler tag content =
try
let result = E.to_document ~tag content in
Some {
Odoc_extension_registry.content = result.content;
overrides = result.overrides;
resources = result.resources;
assets = result.assets;
}
with Unsupported_tag _ -> None
in
Odoc_extension_registry.register_handler ~prefix:E.prefix handler
let register_with_link (module E : Extension_with_link) =
let handler tag content =
try
let result = E.to_document ~tag content in
Some {
Odoc_extension_registry.content = result.content;
overrides = result.overrides;
resources = result.resources;
assets = result.assets;
}
with Unsupported_tag _ -> None
in
Odoc_extension_registry.register_handler ~prefix:E.prefix handler;
let link_handler tag env content =
E.link ~tag (Obj.obj env) content
in
Odoc_extension_registry.register_link_handler ~prefix:E.prefix link_handler
let register_code_block (module E : Code_Block_Extension) =
let handler meta content =
match E.to_document meta content with
| Some result ->
Some {
Odoc_extension_registry.content = result.content;
overrides = result.overrides;
resources = result.resources;
assets = result.assets;
}
| None -> None
in
Odoc_extension_registry.register_code_block_handler ~prefix:E.prefix handler
(** Register a support file for this extension.
The file will be output when [odoc support-files] is run. *)
let register_support_file ~prefix file =
Odoc_extension_registry.register_support_file ~prefix file
let find prefix =
Odoc_extension_registry.find_handler ~prefix
let find_code_block prefix =
Odoc_extension_registry.find_code_block_handler ~prefix
let list_prefixes () =
Odoc_extension_registry.list_prefixes ()
let list_code_block_prefixes () =
Odoc_extension_registry.list_code_block_prefixes ()
let list_support_files () =
Odoc_extension_registry.list_support_files ()
(** Register documentation for an extension.
This will be displayed by [odoc extensions]. *)
let register_extension_info info =
Odoc_extension_registry.register_extension_info info
(** List all registered extension documentation *)
let list_extension_infos () =
Odoc_extension_registry.list_extension_infos ()
end
(** {1 Helper Functions} *)
(** Convert Comment AST to Block elements, preserving references and formatting.
This is the proper way to convert tag content to renderable blocks. *)
let blocks_of_nestable_elements = Odoc_document.Comment.nestable_block_element_list
(** Extract plain text from nestable block elements (for simple parsing) *)
let rec text_of_inline (inline : Comment.inline_element Location_.with_location) =
match inline.Location_.value with
| `Space -> " "
| `Word w -> w
| `Code_span c -> c
| `Math_span m -> m
| `Raw_markup (_, r) -> r
| `Styled (_, inlines) -> text_of_inlines inlines
| `Reference (_, content) -> text_of_link_content content
| `Link (_, content) -> text_of_link_content content
and text_of_inlines inlines =
String.concat "" (List.map text_of_inline inlines)
and text_of_link_content (content : Comment.link_content) =
String.concat "" (List.map text_of_non_link content)
and text_of_non_link (el : Comment.non_link_inline_element Location_.with_location) =
match el.Location_.value with
| `Space -> " "
| `Word w -> w
| `Code_span c -> c
| `Math_span m -> m
| `Raw_markup (_, r) -> r
| `Styled (_, content) -> text_of_link_content content
let text_of_paragraph (p : Comment.paragraph) =
text_of_inlines p
let rec text_of_nestable_block_elements elements =
let buf = Buffer.create 256 in
List.iter (fun (el : Comment.nestable_block_element Location_.with_location) ->
match el.Location_.value with
| `Paragraph p -> Buffer.add_string buf (text_of_paragraph p)
| `Code_block c -> Buffer.add_string buf c.content.Location_.value
| `Math_block m -> Buffer.add_string buf m
| `Verbatim v -> Buffer.add_string buf v
| `Modules _ -> ()
| `Table _ -> ()
| `List (_, items) ->
List.iter (fun item ->
Buffer.add_string buf (text_of_nestable_block_elements item)
) items
| `Media _ -> ()
) elements;
Buffer.contents buf
(** Create a simple paragraph block *)
let paragraph text =
let inline = Inline.[ { attr = []; desc = Text text } ] in
Block.[ { attr = []; desc = Paragraph inline } ]
(** Create an inline link *)
let link ~url ~text =
Inline.[{
attr = [];
desc = Link {
target = External url;
content = [{ attr = []; desc = Text text }];
tooltip = None
}
}]
(** Create an empty extension output with just content *)
let simple_output content =
{ content; overrides = []; resources = []; assets = [] }
(** {1 Code Block Metadata Helpers} *)
(** Get the value of a binding from code block tags.
E.g., for [{@dot width=500[...]}], [get_binding "width" meta.tags]
returns [Some "500"]. *)
let get_binding key tags =
List.find_map (function
| `Binding (k, v) ->
if k.Odoc_parser.Loc.value = key then Some v.Odoc_parser.Loc.value
else None
| `Tag _ -> None
) tags
(** Check if a bare tag is present in code block tags.
E.g., for [{@ocaml line-numbers[...]}], [has_tag "line-numbers" meta.tags]
returns [true]. *)
let has_tag name tags =
List.exists (function
| `Tag t -> t.Odoc_parser.Loc.value = name
| `Binding _ -> false
) tags
(** Get all bindings as a list of (key, value) pairs *)
let get_all_bindings tags =
List.filter_map (function
| `Binding (k, v) -> Some (k.Odoc_parser.Loc.value, v.Odoc_parser.Loc.value)
| `Tag _ -> None
) tags
(** Get all bare tags as a list of names *)
let get_all_tags tags =
List.filter_map (function
| `Tag t -> Some t.Odoc_parser.Loc.value
| `Binding _ -> None
) tags