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
(** 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 Comment = Odoc_model.Comment
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 page (HTML only) *)
type resource = Odoc_extension_registry.resource =
  | Js_url of string      (** External JavaScript: <script src="..."> *)
  | Css_url of string     (** External CSS: <link rel="stylesheet" href="..."> *)
  | Js_inline of string   (** Inline JavaScript: <script>...</script> *)
  | Css_inline of string  (** Inline CSS: <style>...</style> *)

(** 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. *)

  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 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

(** {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 = Odoc_extension_registry.support_file = {
  filename : string;  (** Relative path, e.g., "extensions/admonition.css" *)
  content : string;   (** 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_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} *)

(** 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