A fork of mtelver's day10 project

Implement standalone shell: inline CSS/JS, Google Fonts CDN

Self-contained HTML shell plugin that inlines all CSS and JS into
each page. Font URLs rewritten to Google Fonts CDN for odoc fonts
and jsDelivr CDN for KaTeX fonts.

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

+387
+387
odoc-standalone/src/odoc_standalone_shell.ml
··· 1 + (* odoc-standalone: A self-contained HTML shell plugin for odoc. 2 + Inlines all CSS and JavaScript into each page so that generated HTML 3 + files work without a separate support-files directory. Fonts are 4 + loaded from Google Fonts CDN. 5 + Registers as the "standalone" shell, usable with --shell standalone. *) 6 + 7 + open Odoc_utils 8 + module Html = Tyxml.Html 9 + module Url = Odoc_document.Url 10 + 11 + (* --- String helper --- *) 12 + 13 + let replace_all ~pattern ~with_ s = 14 + String.concat ~sep:with_ (String.cuts ~sep:pattern s) 15 + 16 + (* --- Google Fonts CDN URL mapping --- *) 17 + 18 + let font_cdn_urls = 19 + [ 20 + ( "fonts/fira-sans-v17-latin-regular.woff2", 21 + "https://fonts.gstatic.com/s/firasans/v18/va9E4kDNxMZdWfMOD5Vvl4jL.woff2" 22 + ); 23 + ( "fonts/fira-sans-v17-latin-italic.woff2", 24 + "https://fonts.gstatic.com/s/firasans/v18/va9C4kDNxMZdWfMOD5VvkrjJYTI.woff2" 25 + ); 26 + ( "fonts/fira-sans-v17-latin-500.woff2", 27 + "https://fonts.gstatic.com/s/firasans/v18/va9B4kDNxMZdWfMOD5VnZKveRhf6.woff2" 28 + ); 29 + ( "fonts/fira-sans-v17-latin-500italic.woff2", 30 + "https://fonts.gstatic.com/s/firasans/v18/va9f4kDNxMZdWfMOD5VvkrA6Qif4VFk.woff2" 31 + ); 32 + ( "fonts/fira-sans-v17-latin-700.woff2", 33 + "https://fonts.gstatic.com/s/firasans/v18/va9B4kDNxMZdWfMOD5VnLK3eRhf6.woff2" 34 + ); 35 + ( "fonts/fira-sans-v17-latin-700italic.woff2", 36 + "https://fonts.gstatic.com/s/firasans/v18/va9f4kDNxMZdWfMOD5VvkrByRCf4VFk.woff2" 37 + ); 38 + ( "fonts/fira-mono-v14-latin-regular.woff2", 39 + "https://fonts.gstatic.com/s/firamono/v16/N0bX2SlFPv1weGeLZDtgJv7S.woff2" 40 + ); 41 + ( "fonts/fira-mono-v14-latin-500.woff2", 42 + "https://fonts.gstatic.com/s/firamono/v16/N0bS2SlFPv1weGeLZDto1d3HnvfU.woff2" 43 + ); 44 + ( "fonts/noticia-text-v15-latin-regular.woff2", 45 + "https://fonts.gstatic.com/s/noticiatext/v16/VuJ2dNDF2Yv9qppOePKYRP12ZjtY.woff2" 46 + ); 47 + ( "fonts/noticia-text-v15-latin-italic.woff2", 48 + "https://fonts.gstatic.com/s/noticiatext/v16/VuJodNDF2Yv9qppOePKYRP12Ywtan04.woff2" 49 + ); 50 + ( "fonts/noticia-text-v15-latin-700.woff2", 51 + "https://fonts.gstatic.com/s/noticiatext/v16/VuJpdNDF2Yv9qppOePKYRP1-3R5NuGvQ.woff2" 52 + ); 53 + ] 54 + 55 + (* Rewrite @font-face src URLs in odoc.css to use Google Fonts CDN *) 56 + let rewrite_font_urls css = 57 + List.fold_left 58 + (fun css (local_path, cdn_url) -> 59 + replace_all ~pattern:("url('" ^ local_path ^ "')") ~with_:("url('" ^ cdn_url ^ "')") css) 60 + css font_cdn_urls 61 + 62 + (* --- Reading support files --- *) 63 + 64 + (* Read a built-in support file from Odoc_html_support_files *) 65 + let read_support_file name = 66 + match Odoc_html_support_files.read name with 67 + | Some content -> content 68 + | None -> failwith (Printf.sprintf "odoc-standalone: missing support file %S" name) 69 + 70 + (* Read the content of a support file registered by an extension *) 71 + let read_extension_support_file (file : Odoc_extension_registry.support_file) = 72 + match file.content with 73 + | Inline s -> s 74 + | Copy_from path -> 75 + let ic = open_in_bin path in 76 + Fun.protect ~finally:(fun () -> close_in ic) (fun () -> 77 + let n = in_channel_length ic in 78 + let s = Bytes.create n in 79 + really_input ic s 0 n; 80 + Bytes.to_string s) 81 + 82 + (* Look up an extension support file by its filename *) 83 + let find_extension_support_file filename = 84 + let files = Odoc_extension_registry.list_support_files () in 85 + List.find_opt 86 + (fun (f : Odoc_extension_registry.support_file) -> f.filename = filename) 87 + files 88 + 89 + (* --- TyXML helpers (same patterns as default shell) --- *) 90 + 91 + let html_of_toc toc = 92 + let open Odoc_html.Types in 93 + let rec section (s : toc) = 94 + let link = Html.a ~a:[ Html.a_href s.href ] s.title in 95 + match s.children with [] -> [ link ] | cs -> [ link; sections cs ] 96 + and sections the_sections = 97 + the_sections 98 + |> List.map (fun s -> Html.li (section s)) 99 + |> Html.ul 100 + in 101 + match toc with [] -> [] | _ -> [ sections toc ] 102 + 103 + let sidebars ~global_toc ~local_toc = 104 + let local_toc = 105 + match local_toc with 106 + | [] -> [] 107 + | _ :: _ -> 108 + [ 109 + Html.nav 110 + ~a:[ Html.a_class [ "odoc-toc"; "odoc-local-toc" ] ] 111 + (html_of_toc local_toc); 112 + ] 113 + in 114 + let global_toc = 115 + match global_toc with 116 + | None -> [] 117 + | Some c -> 118 + [ Html.nav ~a:[ Html.a_class [ "odoc-toc"; "odoc-global-toc" ] ] c ] 119 + in 120 + match local_toc @ global_toc with 121 + | [] -> [] 122 + | tocs -> [ Html.div ~a:[ Html.a_class [ "odoc-tocs" ] ] tocs ] 123 + 124 + let html_of_breadcrumbs (breadcrumbs : Odoc_html.Types.breadcrumbs) = 125 + let make_navigation ~up_url rest = 126 + let up = 127 + match up_url with 128 + | None -> [] 129 + | Some up_url -> 130 + [ Html.a ~a:[ Html.a_href up_url ] [ Html.txt "Up" ]; Html.txt " – " ] 131 + in 132 + [ Html.nav ~a:[ Html.a_class [ "odoc-nav" ] ] (up @ rest) ] 133 + in 134 + let space = Html.txt " " in 135 + let sep = [ space; Html.entity "#x00BB"; space ] in 136 + let html = 137 + List.concat_map_sep ~sep 138 + ~f:(fun (breadcrumb : Odoc_html.Types.breadcrumb) -> 139 + match breadcrumb.href with 140 + | Some href -> 141 + [ 142 + [ 143 + Html.a 144 + ~a:[ Html.a_href href ] 145 + (breadcrumb.name 146 + :> Html_types.flow5_without_interactive Html.elt list); 147 + ]; 148 + ] 149 + | None -> 150 + [ (breadcrumb.name :> Html_types.nav_content_fun Html.elt list) ]) 151 + breadcrumbs.parents 152 + |> List.flatten 153 + in 154 + let current_name :> Html_types.nav_content_fun Html.elt list = 155 + breadcrumbs.current.name 156 + in 157 + let rest = 158 + if List.is_empty breadcrumbs.parents then current_name 159 + else html @ sep @ current_name 160 + in 161 + make_navigation ~up_url:breadcrumbs.up_url 162 + (rest :> [< Html_types.nav_content_fun > `A `PCDATA `Wbr ] Html.elt list) 163 + 164 + (* --- Inline resource resolution --- *) 165 + 166 + (* Deduplicate resources while preserving order (keep first occurrence) *) 167 + let deduplicate_resources resources = 168 + let rec aux seen acc = function 169 + | [] -> List.rev acc 170 + | r :: rest -> 171 + if List.mem r seen then aux seen acc rest 172 + else aux (r :: seen) (r :: acc) rest 173 + in 174 + aux [] [] resources 175 + 176 + (* Resolve extension resources: Css_url/Js_url are looked up in the 177 + extension support file registry and inlined; Css_inline/Js_inline 178 + are passed through directly. *) 179 + let inline_extension_resources resources = 180 + let open Odoc_extension_registry in 181 + let resources = deduplicate_resources resources in 182 + List.concat_map 183 + (function 184 + | Css_url url -> 185 + (* Try to find this file in the extension support file registry *) 186 + (match find_extension_support_file url with 187 + | Some file -> 188 + let content = read_extension_support_file file in 189 + [ Html.style [ Html.cdata_style content ] ] 190 + | None -> 191 + (* Absolute URLs or unresolvable: keep as link *) 192 + [ Html.link ~rel:[ `Stylesheet ] ~href:url () ]) 193 + | Js_url url -> 194 + (match find_extension_support_file url with 195 + | Some file -> 196 + let content = read_extension_support_file file in 197 + [ Html.script (Html.cdata_script content) ] 198 + | None -> 199 + [ Html.script ~a:[ Html.a_src url ] (Html.txt "") ]) 200 + | Css_inline code -> 201 + [ Html.style [ Html.cdata_style code ] ] 202 + | Js_inline code -> 203 + [ Html.script (Html.cdata_script code) ]) 204 + resources 205 + 206 + (* --- Page assembly --- *) 207 + 208 + let page_creator ~config ~url ~uses_katex ~resources ~global_toc header 209 + breadcrumbs local_toc content = 210 + let path = Odoc_html.Link.Path.for_printing url in 211 + 212 + (* Read and transform odoc.css: rewrite font URLs to use CDN *) 213 + let odoc_css = rewrite_font_urls (read_support_file "odoc.css") in 214 + 215 + (* Read highlight.pack.js *) 216 + let highlight_js = read_support_file "highlight.pack.js" in 217 + 218 + (* KaTeX support (inline when used) *) 219 + let katex_elements = 220 + if uses_katex then 221 + (* Rewrite KaTeX font URLs to use jsDelivr CDN. 222 + katex.min.css uses url(fonts/KaTeX_...) without quotes. *) 223 + let katex_css = 224 + replace_all 225 + ~pattern:"url(fonts/KaTeX_" 226 + ~with_:"url(https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/fonts/KaTeX_" 227 + (read_support_file "katex.min.css") 228 + in 229 + let katex_js = read_support_file "katex.min.js" in 230 + [ 231 + Html.style [ Html.cdata_style katex_css ]; 232 + Html.script (Html.cdata_script katex_js); 233 + Html.script 234 + (Html.cdata_script 235 + {| 236 + document.addEventListener("DOMContentLoaded", function () { 237 + var macros = {}; 238 + var elements = Array.from(document.getElementsByClassName("odoc-katex-math")); 239 + for (var i = 0; i < elements.length; i++) { 240 + var el = elements[i]; 241 + var content = el.textContent; 242 + var new_el = document.createElement("span"); 243 + new_el.setAttribute("class", "odoc-katex-math-rendered"); 244 + var display = el.classList.contains("display"); 245 + katex.render(content, new_el, { throwOnError: false, displayMode: display, macros }); 246 + el.replaceWith(new_el); 247 + } 248 + }); 249 + |}); 250 + ] 251 + else [] 252 + in 253 + 254 + (* Inline extension resources *) 255 + let extension_elements = inline_extension_resources resources in 256 + 257 + let head : Html_types.head Html.elt = 258 + let title_string = 259 + Printf.sprintf "%s (%s)" url.name (String.concat ~sep:"." path) 260 + in 261 + let meta_elements = 262 + [ 263 + Html.meta ~a:[ Html.a_charset "utf-8" ] (); 264 + Html.meta 265 + ~a: 266 + [ 267 + Html.a_name "viewport"; 268 + Html.a_content "width=device-width,initial-scale=1.0"; 269 + ] 270 + (); 271 + Html.meta 272 + ~a:[ Html.a_name "generator"; Html.a_content "odoc %%VERSION%%" ] 273 + (); 274 + (* Inline odoc.css *) 275 + Html.style [ Html.cdata_style odoc_css ]; 276 + (* Inline highlight.pack.js *) 277 + Html.script (Html.cdata_script highlight_js); 278 + Html.script (Html.txt "hljs.initHighlightingOnLoad();"); 279 + ] 280 + @ katex_elements @ extension_elements 281 + in 282 + Html.head (Html.title (Html.txt title_string)) meta_elements 283 + in 284 + 285 + let body = 286 + html_of_breadcrumbs breadcrumbs 287 + @ [ Html.header ~a:[ Html.a_class [ "odoc-preamble" ] ] header ] 288 + @ sidebars ~global_toc ~local_toc 289 + @ [ Html.div ~a:[ Html.a_class [ "odoc-content" ] ] content ] 290 + in 291 + 292 + let htmlpp = Html.pp ~indent:(Odoc_html.Config.indent config) () in 293 + let html = Html.html head (Html.body ~a:[ Html.a_class [ "odoc" ] ] body) in 294 + let content ppf = 295 + htmlpp ppf html; 296 + Format.pp_force_newline ppf () 297 + in 298 + content 299 + 300 + let make ~config ~url ~header ~breadcrumbs ~sidebar ~toc ~uses_katex ~resources 301 + ~assets content children = 302 + let filename = Odoc_html.Link.Path.as_filename ~config url in 303 + let content = 304 + page_creator ~config ~url ~uses_katex ~resources ~global_toc:sidebar header 305 + breadcrumbs toc content 306 + in 307 + { Odoc_document.Renderer.filename; content; children; path = url; assets } 308 + 309 + let path_of_module_of_source ppf url = 310 + match url.Url.Path.parent with 311 + | Some parent -> 312 + let path = Odoc_html.Link.Path.for_printing parent in 313 + Format.fprintf ppf " (%s)" (String.concat ~sep:"." path) 314 + | None -> () 315 + 316 + let src_page_creator ~breadcrumbs ~config ~url ~header ~sidebar name content = 317 + (* Read and transform odoc.css for source pages too *) 318 + let odoc_css = rewrite_font_urls (read_support_file "odoc.css") in 319 + 320 + let head : Html_types.head Html.elt = 321 + let title_string = 322 + Format.asprintf "Source: %s%a" name path_of_module_of_source url 323 + in 324 + let meta_elements = 325 + [ 326 + Html.meta ~a:[ Html.a_charset "utf-8" ] (); 327 + Html.meta 328 + ~a: 329 + [ 330 + Html.a_name "viewport"; 331 + Html.a_content "width=device-width,initial-scale=1.0"; 332 + ] 333 + (); 334 + Html.meta 335 + ~a:[ Html.a_name "generator"; Html.a_content "odoc %%VERSION%%" ] 336 + (); 337 + Html.style [ Html.cdata_style odoc_css ]; 338 + ] 339 + in 340 + Html.head (Html.title (Html.txt title_string)) meta_elements 341 + in 342 + let body = 343 + html_of_breadcrumbs breadcrumbs 344 + @ [ Html.header ~a:[ Html.a_class [ "odoc-preamble" ] ] header ] 345 + @ sidebars ~global_toc:sidebar ~local_toc:[] 346 + @ content 347 + in 348 + let htmlpp = Html.pp ~indent:false () in 349 + let html = 350 + Html.html head (Html.body ~a:[ Html.a_class [ "odoc-src" ] ] body) 351 + in 352 + let content ppf = 353 + htmlpp ppf html; 354 + Format.pp_force_newline ppf () 355 + in 356 + content 357 + 358 + let make_src ~config ~url ~breadcrumbs ~header ~sidebar title content = 359 + let filename = Odoc_html.Link.Path.as_filename ~config url in 360 + let content = 361 + src_page_creator ~breadcrumbs ~config ~url ~header ~sidebar title content 362 + in 363 + { 364 + Odoc_document.Renderer.filename; 365 + content; 366 + children = []; 367 + path = url; 368 + assets = []; 369 + } 370 + 371 + (* Register as the "standalone" shell *) 372 + let () = 373 + Odoc_html.Html_shell.register 374 + (module struct 375 + let name = "standalone" 376 + 377 + let make ~config (data : Odoc_html.Html_shell.page_data) = 378 + make ~config ~url:data.url 379 + ~header:(data.header @ data.preamble) 380 + ~breadcrumbs:data.breadcrumbs ~sidebar:data.sidebar ~toc:data.toc 381 + ~uses_katex:data.uses_katex ~resources:data.resources 382 + ~assets:data.assets data.content data.children 383 + 384 + let make_src ~config (data : Odoc_html.Html_shell.src_page_data) = 385 + make_src ~config ~url:data.url ~breadcrumbs:data.breadcrumbs 386 + ~header:data.header ~sidebar:data.sidebar data.title data.content 387 + end)