Source file odoc_extension_registry.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
(** Odoc Extension Registry
This module provides a minimal registry for odoc tag extensions.
It is kept separate to avoid circular dependencies between
odoc_document and odoc_extension_api.
*)
module Location_ = Odoc_model.Location_
(** {2 Page Resources}
Resources that an extension requests to be injected into the HTML
[<head>]. The shell plugin is responsible for rendering these; the
behaviour described below applies to the {b docsite} shell shipped
with [odoc-docsite].
{3 Execution timing}
On the {b initial full page load} every resource listed by every
extension present on that page is emitted into [<head>] in order.
External scripts ([Js_url]) are loaded via a normal
[<script src="…">] tag and execute when the browser fetches them.
Inline scripts ([Js_inline]) execute synchronously in document order.
{3 SPA (single-page app) navigation}
The docsite shell intercepts link clicks and fetches pages via
[fetch()] instead of a full reload. After swapping the page content
it reconciles [<head>] resources:
{ul
{- {b [Js_url] / [Css_url]:} Compared by resolved absolute URL.
Resources already present in the live document are skipped;
new ones are appended to [<head>] and (for scripts) their
[onload] events are awaited before continuing.}
{- {b [Js_inline]:} Each inline script is stamped at HTML-generation
time with a [data-spa-inline] attribute containing a hash of its
content. On SPA navigation the shell checks whether a [<script>]
with the same [data-spa-inline] value already exists in [<head>].
If so it is {b not re-executed}. This means inline scripts run
{b exactly once} across SPA navigations — the first time a page
carrying that script is visited.}
{- {b [Css_inline]:} Injected into [<head>] on every navigation
(CSS is additive and idempotent, so duplicates are harmless).}}
{3 Guidance for extension authors}
{ul
{- Prefer [Js_url] for library code (e.g. the mermaid runtime).
The deduplication-by-URL ensures it is loaded exactly once.}
{- Use [Js_inline] for one-time initialisation that must run after
the library is loaded (e.g. registering a global observer).
It will execute once; on subsequent SPA navigations the library
and observer are still alive.}
{- If your extension needs to re-process content on every SPA
navigation, set up a [MutationObserver] or listen for the
["popstate"] event in your [Js_inline] init script rather than
relying on the script being re-executed.}
{- Do {b not} gate initialisation on [DOMContentLoaded] inside a
[Js_inline] script — that event does not re-fire during SPA
navigation.}} *)
type resource =
| Js_url of string
| Css_url of string
| Js_inline of string
| Css_inline of string
(** Content of a support file: either inline string or path to copy from disk *)
type support_file_content =
| Inline of string
| Copy_from of string (** Absolute path to source file *)
(** Support files that extensions want to output *)
type support_file = {
filename : string; (** Relative path, e.g., "extensions/admonition.css" *)
content : support_file_content;
}
(** Binary asset generated by an extension (e.g., rendered PNG) *)
type asset = {
asset_filename : string; (** Filename for the asset, e.g., "diagram-1.png" *)
asset_content : bytes; (** Binary content *)
}
(** Documentation for an extension option *)
type 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 = {
info_kind : [ `Tag | `Code_block ]; (** Type of extension *)
info_prefix : string; (** The prefix this extension handles *)
info_description : string; (** Short description of what it does *)
info_options : option_doc list; (** Supported options *)
info_example : string option; (** Example usage *)
}
(** Result of processing a custom tag.
We use a record with a polymorphic content type that gets
instantiated with the actual Block.t by odoc_document. *)
type 'block extension_result = {
content : 'block;
overrides : (string * string) list;
resources : resource list;
assets : asset list;
(** Binary assets to write alongside the HTML output.
Use [__ODOC_ASSET__filename__] placeholder in content to reference. *)
}
(** Type of handler functions stored in the registry.
The handler takes a tag name and content, returns an optional result.
If None, the tag is handled by the default mechanism. *)
type 'block handler =
string ->
Comment.nestable_block_element Location_.with_location list ->
'block extension_result option
(** The registry stores handlers indexed by prefix *)
let handlers : (string, Obj.t) Hashtbl.t = Hashtbl.create 16
(** Registered prefixes for listing *)
let prefixes : (string, unit) Hashtbl.t = Hashtbl.create 16
(** Support files registered by extensions *)
let support_files : (string, support_file) Hashtbl.t = Hashtbl.create 16
let register_handler ~prefix (handler : 'block handler) =
Hashtbl.replace handlers prefix (Obj.repr handler);
Hashtbl.replace prefixes prefix ()
let register_support_file ~prefix (file : support_file) =
let key = prefix ^ ":" ^ file.filename in
Hashtbl.replace support_files key file
let find_handler (type block) ~prefix : block handler option =
match Hashtbl.find_opt handlers prefix with
| None -> None
| Some h -> Some (Obj.obj h)
let list_prefixes () =
Hashtbl.fold (fun prefix () acc -> prefix :: acc) prefixes []
|> List.sort String.compare
let list_support_files () =
Hashtbl.fold (fun _ file acc -> file :: acc) support_files []
(** Extract the prefix from a tag name (part before the first dot) *)
let prefix_of_tag tag =
match String.index_opt tag '.' with
| None -> tag
| Some i -> String.sub tag 0 i
(** {1 Code Block Handlers}
Similar to custom tag handlers, but for code blocks like [{@dot[...]}].
Handlers can transform code blocks based on language and metadata.
*)
(** Metadata for code blocks, extracted from parser AST *)
type code_block_meta = {
language : string;
tags : Odoc_parser.Ast.code_block_tag list;
}
(** Type of code block handler functions.
Takes metadata and code content, returns optional transformed result. *)
type 'block code_block_handler =
code_block_meta ->
string ->
'block extension_result option
(** Registry for code block handlers, indexed by language prefix *)
let code_block_handlers : (string, Obj.t) Hashtbl.t = Hashtbl.create 16
(** Registered code block prefixes *)
let code_block_prefixes : (string, unit) Hashtbl.t = Hashtbl.create 16
let register_code_block_handler ~prefix (handler : 'block code_block_handler) =
Hashtbl.replace code_block_handlers prefix (Obj.repr handler);
Hashtbl.replace code_block_prefixes prefix ()
let find_code_block_handler (type block) ~prefix : block code_block_handler option =
match Hashtbl.find_opt code_block_handlers prefix with
| None -> None
| Some h -> Some (Obj.obj h)
let list_code_block_prefixes () =
Hashtbl.fold (fun prefix () acc -> prefix :: acc) code_block_prefixes []
|> List.sort String.compare
(** Extract the prefix from a language tag (part before the first dot) *)
let prefix_of_language = prefix_of_tag
(** {1 Extension Documentation}
Extensions can register documentation that describes their options
and usage. This is displayed by [odoc extensions]. *)
(** Registry for extension documentation *)
let extension_infos : (string, extension_info) Hashtbl.t = Hashtbl.create 16
let register_extension_info (info : extension_info) =
let key = match info.info_kind with
| `Tag -> "tag:" ^ info.info_prefix
| `Code_block -> "code:" ^ info.info_prefix
in
Hashtbl.replace extension_infos key info
let list_extension_infos () =
Hashtbl.fold (fun _ info acc -> info :: acc) extension_infos []
|> List.sort (fun a b -> String.compare a.info_prefix b.info_prefix)
(** {1 Link Handlers}
Extensions can register a link handler that runs during the linking
phase with access to the cross-reference environment. The handler
receives the tag name, the environment (as [Obj.t] to avoid a
dependency on [odoc_xref2] here), and already-resolved content.
It returns transformed content. *)
type link_handler =
string ->
Obj.t ->
Comment.nestable_block_element Location_.with_location list ->
Comment.nestable_block_element Location_.with_location list
let link_handlers : (string, link_handler) Hashtbl.t = Hashtbl.create 16
let register_link_handler ~prefix (handler : link_handler) =
Hashtbl.replace link_handlers prefix handler
let find_link_handler ~prefix =
Hashtbl.find_opt link_handlers prefix