this repo has no description

Add asset support to extension API for server-side rendering

This adds the infrastructure for extensions to return binary assets
(like PNG images) that will be written alongside HTML output. This
enables server-side rendering of diagrams instead of client-side
JavaScript rendering.

API changes:
- New `asset` type: { asset_filename: string; asset_content: bytes }
- `extension_output` now includes `assets: asset list` field
- `Page.t` includes assets collected from extensions
- `Renderer.page` includes assets to write with the page
- `Renderer.traverse` callback now receives assets parameter

The HTML generator writes assets to the same directory as the HTML file.
Extensions can use `__ODOC_ASSET__filename__` placeholders in HTML content
to reference assets (placeholder substitution to be added in follow-up).

All existing extensions updated to return `assets = []`.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+83 -20
+1
examples/extensions/dot/src/dot_extension.ml
··· 144 144 Api.Js_url viz_full_js_url; 145 145 Api.Js_inline script; 146 146 ]; 147 + assets = []; 147 148 } 148 149 end 149 150
+1
examples/extensions/mermaid/src/mermaid_extension.ml
··· 111 111 Api.Js_url mermaid_js_url; 112 112 Api.Js_inline init_script; 113 113 ]; 114 + assets = []; 114 115 } 115 116 end 116 117
+1
examples/extensions/msc/src/msc_extension.ml
··· 121 121 resources = [ 122 122 Api.Js_inline loader_script; 123 123 ]; 124 + assets = []; 124 125 } 125 126 end 126 127
+23 -2
src/document/comment.ml
··· 39 39 collected := [] 40 40 end 41 41 42 + (** Asset collection for extension handlers. 43 + Assets (binary files like PNGs) are collected during document generation 44 + and written alongside the HTML output. *) 45 + module Assets = struct 46 + let collected : Odoc_extension_registry.asset list ref = ref [] 47 + 48 + let add assets = 49 + collected := !collected @ assets 50 + 51 + let take () = 52 + let result = !collected in 53 + collected := []; 54 + result 55 + 56 + let clear () = 57 + collected := [] 58 + end 59 + 42 60 let source_of_code s = 43 61 if s = "" then [] else [ Source.Elt [ inline @@ Inline.Text s ] ] 44 62 ··· 251 269 in 252 270 (match handler_result with 253 271 | Some result -> 254 - (* Handler produced a result, collect resources and use content *) 272 + (* Handler produced a result, collect resources/assets and use content *) 255 273 Resources.add result.resources; 274 + Assets.add result.assets; 256 275 result.content 257 276 | None -> 258 277 (* No handler or handler declined, use default rendering *) ··· 434 453 | Some handler -> 435 454 (match handler name content with 436 455 | Some result -> 437 - (* Extension handled the tag - use its output *) 456 + (* Extension handled the tag - collect resources/assets and use output *) 457 + Resources.add result.Odoc_extension_registry.resources; 458 + Assets.add result.Odoc_extension_registry.assets; 438 459 { Description.attr = [ name ]; 439 460 key = [ inline ~attr:[ "at-tag" ] (Text name) ]; 440 461 definition = result.Odoc_extension_registry.content }
+4 -2
src/document/generator.ml
··· 88 88 let comment = List.concat comments in 89 89 let preamble, items = prepare_preamble comment items in 90 90 let resources = Comment.Resources.take () in 91 - { Page.preamble; items; url; source_anchor; resources } 91 + let assets = Comment.Assets.take () in 92 + { Page.preamble; items; url; source_anchor; resources; assets } 92 93 93 94 include Generator_signatures 94 95 ··· 1815 1816 let preamble, items = Sectioning.docs t.content.elements in 1816 1817 let source_anchor = None in 1817 1818 let resources = Comment.Resources.take () in 1818 - Document.Page { Page.preamble; items; url; source_anchor; resources } 1819 + let assets = Comment.Assets.take () in 1820 + Document.Page { Page.preamble; items; url; source_anchor; resources; assets } 1819 1821 1820 1822 let implementation (v : Odoc_model.Lang.Implementation.t) syntax_info 1821 1823 source_code =
+3 -1
src/document/renderer.ml
··· 9 9 path : Url.Path.t; 10 10 content : Format.formatter -> unit; 11 11 children : page list; 12 + assets : Odoc_extension_registry.asset list; 13 + (** Binary assets to write alongside this page *) 12 14 } 13 15 14 16 let traverse ~f t = 15 17 let rec aux node = 16 - f node.filename node.content; 18 + f node.filename node.content node.assets; 17 19 List.iter aux node.children 18 20 in 19 21 List.iter aux t
+2
src/document/types.ml
··· 187 187 or a sub part. *) 188 188 resources : Odoc_extension_registry.resource list; 189 189 (** Resources (JS/CSS) to inject into the page, collected from extensions. *) 190 + assets : Odoc_extension_registry.asset list; 191 + (** Binary assets to write alongside this page's HTML output. *) 190 192 } 191 193 end = 192 194 Page
+16 -1
src/extension_api/odoc_extension_api.ml
··· 27 27 | Js_inline of string (** Inline JavaScript: <script>...</script> *) 28 28 | Css_inline of string (** Inline CSS: <style>...</style> *) 29 29 30 + (** Binary asset generated by an extension. 31 + Assets are written alongside the HTML output. To reference an asset 32 + in your content, use the placeholder [__ODOC_ASSET__filename__] which 33 + will be replaced with the correct relative path during HTML generation. *) 34 + type asset = Odoc_extension_registry.asset = { 35 + asset_filename : string; (** Filename for the asset, e.g., "diagram-1.png" *) 36 + asset_content : bytes; (** Binary content *) 37 + } 38 + 30 39 (** Output from the document phase *) 31 40 type extension_output = { 32 41 content : Block.t; ··· 38 47 39 48 resources : resource list; 40 49 (** Page-level resources (JS/CSS). Only used by HTML backend. *) 50 + 51 + assets : asset list; 52 + (** Binary assets to write alongside HTML output. 53 + Reference in content using [__ODOC_ASSET__filename__] placeholder. *) 41 54 } 42 55 43 56 (** Raised when an extension receives a tag variant it doesn't support *) ··· 124 137 Odoc_extension_registry.content = result.content; 125 138 overrides = result.overrides; 126 139 resources = result.resources; 140 + assets = result.assets; 127 141 } 128 142 with Unsupported_tag _ -> None 129 143 in ··· 137 151 Odoc_extension_registry.content = result.content; 138 152 overrides = result.overrides; 139 153 resources = result.resources; 154 + assets = result.assets; 140 155 } 141 156 | None -> None 142 157 in ··· 231 246 232 247 (** Create an empty extension output with just content *) 233 248 let simple_output content = 234 - { content; overrides = []; resources = [] } 249 + { content; overrides = []; resources = []; assets = [] } 235 250 236 251 (** {1 Code Block Metadata Helpers} *) 237 252
+9
src/extension_registry/odoc_extension_registry.ml
··· 21 21 content : string; (** File content *) 22 22 } 23 23 24 + (** Binary asset generated by an extension (e.g., rendered PNG) *) 25 + type asset = { 26 + asset_filename : string; (** Filename for the asset, e.g., "diagram-1.png" *) 27 + asset_content : bytes; (** Binary content *) 28 + } 29 + 24 30 (** Result of processing a custom tag. 25 31 We use a record with a polymorphic content type that gets 26 32 instantiated with the actual Block.t by odoc_document. *) ··· 28 34 content : 'block; 29 35 overrides : (string * string) list; 30 36 resources : resource list; 37 + assets : asset list; 38 + (** Binary assets to write alongside the HTML output. 39 + Use [__ODOC_ASSET__filename__] placeholder in content to reference. *) 31 40 } 32 41 33 42 (** Type of handler functions stored in the registry.
+2 -2
src/html/generator.ml
··· 652 652 List.map (include_ ~config ~sidebar) subpages 653 653 654 654 and page ~config ~sidebar p : Odoc_document.Renderer.page = 655 - let { Page.preamble = _; items = i; url; source_anchor; resources } = 655 + let { Page.preamble = _; items = i; url; source_anchor; resources; assets } = 656 656 Doctree.Labels.disambiguate_page ~enter_subpages:false p 657 657 in 658 658 let subpages = subpages ~config ~sidebar @@ Doctree.Subpages.compute p in ··· 684 684 subpages 685 685 else 686 686 Html_page.make ~sidebar ~config ~header:(header @ preamble) ~toc 687 - ~breadcrumbs ~url ~uses_katex ~resources content subpages 687 + ~breadcrumbs ~url ~uses_katex ~resources ~assets content subpages 688 688 689 689 and source_page ~config ~sidebar sp = 690 690 let { Source_page.url; contents } = sp in
+2 -2
src/html/html_fragment_json.ml
··· 64 64 ("content", `String (json_of_html config content)); 65 65 ])) 66 66 in 67 - { Odoc_document.Renderer.filename; content; children; path = url } 67 + { Odoc_document.Renderer.filename; content; children; path = url; assets = [] } 68 68 69 69 let make_src ~config ~url ~breadcrumbs ~sidebar ~header content = 70 70 let filename = Link.Path.as_filename ~config url in ··· 87 87 (List.map (Format.asprintf "%a" htmlpp) content)) ); 88 88 ])) 89 89 in 90 - { Odoc_document.Renderer.filename; content; children = []; path = url } 90 + { Odoc_document.Renderer.filename; content; children = []; path = url; assets = [] }
+3 -3
src/html/html_page.ml
··· 264 264 content 265 265 266 266 let make ~config ~url ~header ~breadcrumbs ~sidebar ~toc ~uses_katex ~resources 267 - content children = 267 + ~assets content children = 268 268 let filename = Link.Path.as_filename ~config url in 269 269 let content = 270 270 page_creator ~config ~url ~uses_katex ~resources ~global_toc:sidebar header 271 271 breadcrumbs toc content 272 272 in 273 - { Odoc_document.Renderer.filename; content; children; path = url } 273 + { Odoc_document.Renderer.filename; content; children; path = url; assets } 274 274 275 275 let path_of_module_of_source ppf url = 276 276 match url.Url.Path.parent with ··· 311 311 let content = 312 312 src_page_creator ~breadcrumbs ~config ~url ~header ~sidebar title content 313 313 in 314 - { Odoc_document.Renderer.filename; content; children = []; path = url } 314 + { Odoc_document.Renderer.filename; content; children = []; path = url; assets = [] }
+1
src/html/html_page.mli
··· 29 29 toc:Types.toc list -> 30 30 uses_katex:bool -> 31 31 resources:Odoc_extension_registry.resource list -> 32 + assets:Odoc_extension_registry.asset list -> 32 33 Html_types.div_content Html.elt list -> 33 34 Odoc_document.Renderer.page list -> 34 35 Odoc_document.Renderer.page
+1 -1
src/latex/generator.ml
··· 522 522 if config.with_children then link_children ppf children else () 523 523 in 524 524 let content ppf = Fmt.pf ppf "@[<v>%a@,%t@]@." pp content children_input in 525 - { Odoc_document.Renderer.filename; content; children; path = url } 525 + { Odoc_document.Renderer.filename; content; children; path = url; assets = [] } 526 526 end 527 527 528 528 module Page = struct
+1 -1
src/manpage/generator.ml
··· 562 562 and children = List.concat_map subpage (Subpages.compute p) in 563 563 let content ppf = Format.fprintf ppf "%a@." Roff.pp (page p) in 564 564 let filename = Link.as_filename p.url in 565 - { Renderer.filename; content; children; path = p.url } 565 + { Renderer.filename; content; children; path = p.url; assets = [] } 566 566 567 567 let render = function 568 568 | Document.Page page -> [ render_page page ]
+2 -2
src/markdown2/markdown_page.ml
··· 3 3 let make ~config ~url doc children = 4 4 let filename = Link.Path.as_filename ~config url in 5 5 let content ppf = Format.fprintf ppf "%s" (Renderer.to_string doc) in 6 - { Odoc_document.Renderer.filename; content; children; path = url } 6 + { Odoc_document.Renderer.filename; content; children; path = url; assets = [] } 7 7 8 8 let make_src ~config ~url _title block_list = 9 9 let filename = Link.Path.as_filename ~config url in ··· 12 12 let doc = root_block in 13 13 Format.fprintf ppf "%s" (Renderer.to_string doc) 14 14 in 15 - { Odoc_document.Renderer.filename; content; children = []; path = url } 15 + { Odoc_document.Renderer.filename; content; children = []; path = url; assets = [] }
+11 -3
src/odoc/rendering.ml
··· 52 52 let render_document renderer ~sidebar ~output:root_dir ~extra_suffix ~extra doc 53 53 = 54 54 let pages = renderer.Renderer.render extra sidebar doc in 55 - Renderer.traverse pages ~f:(fun filename content -> 55 + Renderer.traverse pages ~f:(fun filename content assets -> 56 56 let filename = prepare ~extra_suffix ~output_dir:root_dir filename in 57 + (* Write assets to the same directory as the HTML file *) 58 + let asset_dir = Fpath.parent filename in 59 + List.iter (fun (asset : Odoc_extension_registry.asset) -> 60 + let asset_path = Fpath.(asset_dir / asset.asset_filename) in 61 + Io_utils.with_open_out_bin (Fs.File.to_string asset_path) @@ fun oc -> 62 + output_bytes oc asset.asset_content 63 + ) assets; 64 + (* Write the HTML content *) 57 65 Io_utils.with_formatter_out (Fs.File.to_string filename) @@ fun fmt -> 58 66 Format.fprintf fmt "%t@?" content) 59 67 ··· 131 139 in 132 140 doc >>= fun doc -> 133 141 let pages = renderer.Renderer.render extra None doc in 134 - Renderer.traverse pages ~f:(fun filename _content -> 142 + Renderer.traverse pages ~f:(fun filename _content _assets -> 135 143 let filename = Fpath.normalize @@ Fs.File.append root_dir filename in 136 144 Format.printf "%a\n" Fpath.pp filename); 137 145 Ok () ··· 146 154 List.iter 147 155 (fun doc -> 148 156 let pages = renderer.Renderer.render extra None doc in 149 - Renderer.traverse pages ~f:(fun filename _content -> 157 + Renderer.traverse pages ~f:(fun filename _content _assets -> 150 158 let filename = 151 159 Fpath.normalize @@ Fs.File.append root_dir filename 152 160 in