(* odoc-docsite: A docs.rs-inspired shell plugin for odoc. Registers as the "docsite" shell, usable with --shell docsite. *) open Odoc_utils module Html = Tyxml.Html module Url = Odoc_document.Url (* Register CSS and JS as support files *) let () = Odoc_extension_registry.register_support_file ~prefix:"docsite" { filename = "extensions/docsite.css"; content = Inline Odoc_docsite_css.css; }; Odoc_extension_registry.register_support_file ~prefix:"docsite" { filename = "extensions/docsite.js"; content = Inline Odoc_docsite_js.js; } (* Site title from env var, default "Documentation" *) let site_title = match Sys.getenv_opt "ODOC_SITE_TITLE" with | Some t -> t | None -> "Documentation" (* --- Helpers --- *) let file_uri ~config ~url (base : Odoc_html.Types.uri) file = match base with | Odoc_html.Types.Absolute uri -> uri ^ "/" ^ file | Relative uri -> let page = Url.Path.{ kind = `File; parent = uri; name = file } in Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) (* TyXML helper for generating TOC *) let html_of_toc toc = let open Odoc_html.Types in let rec section (s : toc) = let link = Html.a ~a:[ Html.a_href s.href ] s.title in match s.children with [] -> [ link ] | cs -> [ link; sections cs ] and sections the_sections = the_sections |> List.map (fun s -> Html.li (section s)) |> Html.ul in match toc with [] -> [] | _ -> [ sections toc ] let toc_section toc = match toc with | [] -> [] | _ -> [ Html.div ~a:[ Html.a_class [ "odoc-tocs" ] ] [ Html.nav ~a:[ Html.a_class [ "odoc-toc"; "odoc-local-toc" ] ] (Html.div ~a:[ Html.a_class [ "odoc-toc-title" ] ] [ Html.txt "On This Page" ] :: html_of_toc toc); ]; ] (* Breadcrumbs *) let html_of_breadcrumbs (breadcrumbs : Odoc_html.Types.breadcrumbs) = let space = Html.txt " " in let sep = [ space; Html.entity "#x00BB"; space ] in let html = List.concat_map_sep ~sep ~f:(fun (breadcrumb : Odoc_html.Types.breadcrumb) -> match breadcrumb.href with | Some href -> [ [ Html.a ~a:[ Html.a_href href ] (breadcrumb.name :> Html_types.flow5_without_interactive Html.elt list); ]; ] | None -> [ (breadcrumb.name :> Html_types.nav_content_fun Html.elt list) ]) breadcrumbs.parents |> List.flatten in let current_name :> Html_types.nav_content_fun Html.elt list = breadcrumbs.current.name in let rest = if List.is_empty breadcrumbs.parents then current_name else html @ sep @ current_name in (rest :> [< Html_types.nav_content_fun > `A `PCDATA `Wbr ] Html.elt list) (* Serialize sidebar data to JSON for inline embedding *) let sidebar_json_script sidebar_data = match sidebar_data with | None -> [] | Some data -> let json = Odoc_html.Sidebar.to_json data in let json_str = Json.to_string json in [ Html.script (Html.cdata_script (Printf.sprintf "window.__DOCSITE_SIDEBAR_DATA__ = %s;" json_str)); ] (* --- Page assembly --- *) let page_creator ~config ~url ~uses_katex ~resources ~sidebar_data ~toc header breadcrumbs content = let support_uri = Odoc_html.Config.support_uri config in let path = Odoc_html.Link.Path.for_printing url in let file_uri = file_uri ~config ~url in let docsite_css_uri = file_uri support_uri "extensions/docsite.css" in let docsite_js_uri = file_uri support_uri "extensions/docsite.js" in (* Compute BASE_URL - relative path from current page to root *) let base_url = let page = Url.Path.{ kind = `File; parent = None; name = "" } in Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) in (* Current URL as relative path from root *) let current_url = let filename = Odoc_html.Link.Path.as_filename ~config url in Fpath.to_string filename in (* Deduplicate resources *) let deduplicate_resources resources = let rec aux seen acc = function | [] -> List.rev acc | r :: rest -> if List.mem r seen then aux seen acc rest else aux (r :: seen) (r :: acc) rest in aux [] [] resources in (* Extension resources *) let extension_head_elements = let open Odoc_extension_registry in let is_absolute_url url = String.is_prefix ~affix:"http://" url || String.is_prefix ~affix:"https://" url in let resources = deduplicate_resources resources in List.concat_map (function | Js_url js_url -> let resolved = if is_absolute_url js_url then js_url else file_uri support_uri js_url in [ Html.script ~a:[ Html.a_src resolved ] (Html.txt "") ] | Css_url css_url -> let resolved = if is_absolute_url css_url then css_url else file_uri support_uri css_url in [ Html.link ~rel:[ `Stylesheet ] ~href:resolved () ] | Js_inline code -> let id = Printf.sprintf "%x" (Hashtbl.hash code land 0x7FFFFFFF) in [ Html.script ~a:[ Html.a_user_data "spa-inline" id ] (Html.cdata_script code) ] | Css_inline code -> [ Html.style [ Html.cdata_style code ] ]) resources in (* KaTeX support *) let katex_elements = if uses_katex then let theme_uri = Odoc_html.Config.theme_uri config in let katex_css_uri = file_uri theme_uri "katex.min.css" in let katex_js_uri = file_uri support_uri "katex.min.js" in [ Html.link ~rel:[ `Stylesheet ] ~href:katex_css_uri (); Html.script ~a:[ Html.a_src katex_js_uri ] (Html.txt ""); Html.script (Html.cdata_script {| document.addEventListener("DOMContentLoaded", function () { var macros = {}; var elements = Array.from(document.getElementsByClassName("odoc-katex-math")); for (var i = 0; i < elements.length; i++) { var el = elements[i]; var content = el.textContent; var new_el = document.createElement("span"); new_el.setAttribute("class", "odoc-katex-math-rendered"); var display = el.classList.contains("display"); katex.render(content, new_el, { throwOnError: false, displayMode: display, macros }); el.replaceWith(new_el); } }); |}); ] else [] in let head : Html_types.head Html.elt = let title_string = Printf.sprintf "%s (%s)" url.name (String.concat ~sep:"." path) in let meta_elements = [ Html.meta ~a:[ Html.a_charset "utf-8" ] (); Html.link ~rel:[ `Stylesheet ] ~href:docsite_css_uri (); Html.meta ~a: [ Html.a_name "viewport"; Html.a_content "width=device-width,initial-scale=1.0"; ] (); Html.meta ~a:[ Html.a_name "generator"; Html.a_content "odoc %%VERSION%%" ] (); (* Inject BASE_URL and CURRENT_URL for the JS *) Html.script (Html.Unsafe.data (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" base_url current_url)); ] @ katex_elements @ extension_head_elements @ sidebar_json_script sidebar_data in Html.head (Html.title (Html.txt title_string)) meta_elements in (* Compute the root-relative href for the brand link *) let brand_href = let index_path = Url.Path.{ kind = `LeafPage; parent = None; name = "index" } in Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path index_path) in let body = [ (* Header bar *) Html.header ~a:[ Html.a_class [ "docsite-header" ] ] [ (* Mobile menu toggle *) Html.Unsafe.data {||}; Html.a ~a: [ Html.a_href brand_href; Html.a_class [ "docsite-header-brand" ]; ] [ Html.txt site_title ]; Html.div ~a:[ Html.a_class [ "docsite-search-container" ] ] [ Html.div ~a:[ Html.a_class [ "search-wrapper" ] ] [ Html.Unsafe.data {| |}; Html.input ~a: [ Html.a_input_type `Text; Html.a_class [ "search-input" ]; Html.a_placeholder "Search documentation..."; ] (); Html.span ~a:[ Html.a_class [ "search-shortcut" ] ] [ Html.txt "/" ]; Html.div ~a:[ Html.a_class [ "search-results" ] ] []; ]; ]; ]; (* Layout: sidebar + main *) Html.div ~a:[ Html.a_class [ "docsite-layout" ] ] [ (* Sidebar *) Html.nav ~a:[ Html.a_class [ "docsite-sidebar" ] ] [ Html.div ~a:[ Html.a_class [ "package-selector" ] ] [ Html.Unsafe.data {||}; ]; Html.div ~a: [ Html.a_class [ "sidebar-nav" ]; Html.a_id "sidebar-content"; ] [ Html.div ~a:[ Html.a_class [ "sidebar-loading" ] ] [ Html.txt "Loading..." ] ]; ]; (* Main content area *) Html.main ~a:[ Html.a_class [ "docsite-main" ] ] ([ Html.nav ~a:[ Html.a_class [ "odoc-nav" ] ] (html_of_breadcrumbs breadcrumbs) ] @ [ Html.div ~a:[ Html.a_class [ "odoc-content" ] ] ((header :> Html_types.div_content Html.elt list) @ content) ] @ toc_section toc); ]; (* Docsite JS *) Html.script ~a:[ Html.a_src docsite_js_uri; Html.a_defer () ] (Html.txt ""); ] in let htmlpp = Html.pp ~indent:(Odoc_html.Config.indent config) () in let html = Html.html head (Html.body ~a:[ Html.a_class [ "odoc"; "odoc-docsite" ] ] body) in let content ppf = htmlpp ppf html; Format.pp_force_newline ppf () in content let make ~config ~url ~header ~breadcrumbs ~sidebar_data ~toc ~uses_katex ~resources ~assets content children = let filename = Odoc_html.Link.Path.as_filename ~config url in let content = page_creator ~config ~url ~uses_katex ~resources ~sidebar_data ~toc header breadcrumbs content in { Odoc_document.Renderer.filename; content; children; path = url; assets } let src_page_creator ~breadcrumbs ~config ~url ~header ~sidebar_data title content = let support_uri = Odoc_html.Config.support_uri config in let file_uri = file_uri ~config ~url in let docsite_css_uri = file_uri support_uri "extensions/docsite.css" in let docsite_js_uri = file_uri support_uri "extensions/docsite.js" in let base_url = let page = Url.Path.{ kind = `File; parent = None; name = "" } in Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) in let current_url = let filename = Odoc_html.Link.Path.as_filename ~config url in Fpath.to_string filename in let brand_href = let index_path = Url.Path.{ kind = `LeafPage; parent = None; name = "index" } in Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path index_path) in let head : Html_types.head Html.elt = let title_string = Printf.sprintf "Source: %s" title in let meta_elements = [ Html.meta ~a:[ Html.a_charset "utf-8" ] (); Html.link ~rel:[ `Stylesheet ] ~href:docsite_css_uri (); Html.meta ~a: [ Html.a_name "viewport"; Html.a_content "width=device-width,initial-scale=1.0"; ] (); Html.script (Html.Unsafe.data (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" base_url current_url)); ] @ sidebar_json_script sidebar_data in Html.head (Html.title (Html.txt title_string)) meta_elements in let body = [ Html.header ~a:[ Html.a_class [ "docsite-header" ] ] [ Html.a ~a: [ Html.a_href brand_href; Html.a_class [ "docsite-header-brand" ]; ] [ Html.txt site_title ]; Html.div ~a:[ Html.a_class [ "docsite-search-container" ] ] [ Html.div ~a:[ Html.a_class [ "search-wrapper" ] ] [ Html.input ~a: [ Html.a_input_type `Text; Html.a_class [ "search-input" ]; Html.a_placeholder "Search documentation..."; ] (); Html.div ~a:[ Html.a_class [ "search-results" ] ] []; ]; ]; ]; Html.div ~a:[ Html.a_class [ "docsite-layout" ] ] [ Html.nav ~a:[ Html.a_class [ "docsite-sidebar" ] ] [ Html.div ~a:[ Html.a_class [ "package-selector" ] ] [ Html.Unsafe.data {||}; ]; Html.div ~a: [ Html.a_class [ "sidebar-nav" ]; Html.a_id "sidebar-content"; ] [ Html.div ~a:[ Html.a_class [ "sidebar-loading" ] ] [ Html.txt "Loading..." ] ]; ]; Html.main ~a:[ Html.a_class [ "docsite-main" ] ] ([ Html.nav ~a:[ Html.a_class [ "odoc-nav" ] ] (html_of_breadcrumbs breadcrumbs) ] @ [ Html.header ~a:[ Html.a_class [ "odoc-preamble" ] ] (header :> Html_types.flow5_without_header_footer Html.elt list) ] @ content); ]; Html.script ~a:[ Html.a_src docsite_js_uri; Html.a_defer () ] (Html.txt ""); ] in let htmlpp = Html.pp ~indent:false () in let html = Html.html head (Html.body ~a:[ Html.a_class [ "odoc-src"; "odoc-docsite" ] ] body) in let content ppf = htmlpp ppf html; Format.pp_force_newline ppf () in content let make_src ~config ~url ~breadcrumbs ~header ~sidebar_data title content = let filename = Odoc_html.Link.Path.as_filename ~config url in let content = src_page_creator ~breadcrumbs ~config ~url ~header ~sidebar_data title content in { Odoc_document.Renderer.filename; content; children = []; path = url; assets = []; } (* Register the shell *) let () = Odoc_html.Html_shell.register (module struct let name = "docsite" let make ~config (data : Odoc_html.Html_shell.page_data) = make ~config ~url:data.url ~header:(data.header @ data.preamble) ~breadcrumbs:data.breadcrumbs ~sidebar_data:data.sidebar_data ~toc:data.toc ~uses_katex:data.uses_katex ~resources:data.resources ~assets:data.assets data.content data.children let make_src ~config (data : Odoc_html.Html_shell.src_page_data) = make_src ~config ~url:data.url ~breadcrumbs:data.breadcrumbs ~header:data.header ~sidebar_data:data.sidebar_data data.title data.content end)