My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge commit '3cd8d38e0d483b7f9da5b0fb3460a6542a7d04f2' as 'odoc-jons-plugins'

+2009
+11
odoc-jons-plugins/dune-project
··· 1 + (lang dune 3.18) 2 + (using dune_site 0.1) 3 + (name odoc-jons-plugins) 4 + (generate_opam_files true) 5 + 6 + (package 7 + (name odoc-jons-plugins) 8 + (synopsis "odoc shell and extensions for jon.recoil.org") 9 + (depends 10 + (ocaml (>= 4.14)) 11 + odoc))
+25
odoc-jons-plugins/odoc-jons-plugins.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "odoc shell and extensions for jon.recoil.org" 4 + depends: [ 5 + "dune" {>= "3.18"} 6 + "ocaml" {>= "4.14"} 7 + "odoc" 8 + ] 9 + build: [ 10 + ["dune" "subst"] {dev} 11 + [ 12 + "dune" 13 + "build" 14 + "-p" 15 + name 16 + "-j" 17 + jobs 18 + "--promote-install-files=false" 19 + "@install" 20 + "@runtest" {with-test} 21 + "@doc" {with-doc} 22 + ] 23 + ["dune" "install" "-p" name "--create-install-files" name] 24 + ] 25 + x-maintenance-intent: ["(latest)"]
+9
odoc-jons-plugins/src/dune
··· 1 + (library 2 + (public_name odoc-jons-plugins.impl) 3 + (name odoc_jons_plugins) 4 + (libraries odoc.html odoc.extension_api)) 5 + 6 + (plugin 7 + (name odoc-jons-plugins) 8 + (libraries odoc-jons-plugins.impl) 9 + (site (odoc extensions)))
+784
odoc-jons-plugins/src/odoc_jons_plugins.ml
··· 1 + (* odoc-jons-plugins: Shell and extensions for jon.recoil.org. 2 + Registers the "jon-shell" shell and metadata tag extensions. *) 3 + 4 + open Odoc_utils 5 + module Html = Tyxml.Html 6 + module Url = Odoc_document.Url 7 + 8 + (* Register CSS and JS as support files *) 9 + let () = 10 + Odoc_extension_registry.register_support_file ~prefix:"jon-shell" 11 + { 12 + filename = "extensions/jon-shell.css"; 13 + content = Inline Odoc_jons_plugins_css.css; 14 + }; 15 + Odoc_extension_registry.register_support_file ~prefix:"jon-shell" 16 + { 17 + filename = "extensions/jon-shell.js"; 18 + content = Inline Odoc_jons_plugins_js.js; 19 + } 20 + 21 + (* Sidebar data is now loaded at runtime from sidebar.json instead of being 22 + embedded inline in every page. This avoids duplicating ~200KB of JSON 23 + across every HTML file and is essential for large package sets. *) 24 + 25 + (* --- Helpers --- *) 26 + 27 + let file_uri ~config ~url (base : Odoc_html.Types.uri) file = 28 + match base with 29 + | Odoc_html.Types.Absolute uri -> uri ^ "/" ^ file 30 + | Relative uri -> 31 + let page = Url.Path.{ kind = `File; parent = uri; name = file } in 32 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 33 + 34 + (* --- Config-driven meta tags --- *) 35 + 36 + let xocaml_meta_tags config = 37 + let prefix = "x-ocaml." in 38 + Odoc_html.Config.config_values config 39 + |> List.filter_map (fun (k, v) -> 40 + match String.cut ~sep:prefix k with 41 + | Some ("", suffix) -> 42 + let meta_name = "x-ocaml-" ^ suffix in 43 + Some 44 + (Html.meta ~a:[ Html.a_name meta_name; Html.a_content v ] ()) 45 + | _ -> None) 46 + 47 + (* --- Page assembly --- *) 48 + 49 + let page_creator ~config ~url ~uses_katex ~resources ~sidebar_data:_ ~header 50 + ~preamble content = 51 + let support_uri = Odoc_html.Config.support_uri config in 52 + let file_uri = file_uri ~config ~url in 53 + let shell_css_uri = file_uri support_uri "extensions/jon-shell.css" in 54 + let shell_js_uri = file_uri support_uri "extensions/jon-shell.js" in 55 + 56 + (* Compute BASE_URL - relative path from current page to root *) 57 + let base_url = 58 + let page = Url.Path.{ kind = `File; parent = None; name = "" } in 59 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 60 + in 61 + 62 + (* Current URL as relative path from root *) 63 + let current_url = 64 + let filename = Odoc_html.Link.Path.as_filename ~config url in 65 + Fpath.to_string filename 66 + in 67 + 68 + (* Deduplicate resources *) 69 + let deduplicate_resources resources = 70 + let rec aux seen acc = function 71 + | [] -> List.rev acc 72 + | r :: rest -> 73 + if List.mem r seen then aux seen acc rest 74 + else aux (r :: seen) (r :: acc) rest 75 + in 76 + aux [] [] resources 77 + in 78 + 79 + (* Extension resources: all go in head for SPA resource discovery *) 80 + let extension_head_elements = 81 + let open Odoc_extension_registry in 82 + let is_absolute_url url = 83 + String.is_prefix ~affix:"http://" url 84 + || String.is_prefix ~affix:"https://" url 85 + in 86 + let resources = deduplicate_resources resources in 87 + List.concat_map 88 + (function 89 + | Css_url css_url -> 90 + let resolved = 91 + if is_absolute_url css_url then css_url 92 + else file_uri support_uri css_url 93 + in 94 + [ Html.link ~rel:[ `Stylesheet ] ~href:resolved () ] 95 + | Css_inline code -> [ Html.style [ Html.cdata_style code ] ] 96 + | Js_url js_url -> 97 + let resolved = 98 + if is_absolute_url js_url then js_url 99 + else file_uri support_uri js_url 100 + in 101 + [ Html.script ~a:[ Html.a_src resolved ] (Html.txt "") ] 102 + | Js_inline code -> 103 + let id = 104 + Printf.sprintf "%x" (Hashtbl.hash code land 0x7FFFFFFF) 105 + in 106 + [ 107 + Html.script 108 + ~a:[ Html.a_user_data "spa-inline" id ] 109 + (Html.cdata_script code); 110 + ]) 111 + resources 112 + in 113 + 114 + (* KaTeX support *) 115 + let katex_elements = 116 + if uses_katex then 117 + let theme_uri = Odoc_html.Config.theme_uri config in 118 + let katex_css_uri = file_uri theme_uri "katex.min.css" in 119 + let katex_js_uri = file_uri support_uri "katex.min.js" in 120 + [ 121 + Html.link ~rel:[ `Stylesheet ] ~href:katex_css_uri (); 122 + Html.script ~a:[ Html.a_src katex_js_uri ] (Html.txt ""); 123 + Html.script 124 + (Html.cdata_script 125 + {| 126 + document.addEventListener("DOMContentLoaded", function () { 127 + var macros = {}; 128 + var elements = Array.from(document.getElementsByClassName("odoc-katex-math")); 129 + for (var i = 0; i < elements.length; i++) { 130 + var el = elements[i]; 131 + var content = el.textContent; 132 + var new_el = document.createElement("span"); 133 + new_el.setAttribute("class", "odoc-katex-math-rendered"); 134 + var display = el.classList.contains("display"); 135 + katex.render(content, new_el, { throwOnError: false, displayMode: display, macros }); 136 + el.replaceWith(new_el); 137 + } 138 + }); 139 + |}); 140 + ] 141 + else [] 142 + in 143 + 144 + let title_string = url.name in 145 + 146 + let head : Html_types.head Html.elt = 147 + let meta_elements = 148 + [ 149 + Html.meta ~a:[ Html.a_charset "utf-8" ] (); 150 + Html.meta 151 + ~a: 152 + [ 153 + Html.a_name "viewport"; 154 + Html.a_content "width=device-width, initial-scale=1"; 155 + ] 156 + (); 157 + Html.link ~rel:[ `Stylesheet ] ~href:shell_css_uri (); 158 + (* Inject BASE_URL and CURRENT_URL for SPA JS *) 159 + Html.script 160 + (Html.Unsafe.data 161 + (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" 162 + base_url current_url)); 163 + ] 164 + @ xocaml_meta_tags config 165 + @ katex_elements @ extension_head_elements 166 + in 167 + Html.head (Html.title (Html.txt title_string)) meta_elements 168 + in 169 + 170 + (* Only render the sidebar container for reference pages — 171 + JS will populate it from sidebar.json *) 172 + let sidebar_nav = 173 + if Astring.String.is_prefix ~affix:"reference/" current_url then 174 + [ 175 + Html.nav 176 + ~a: 177 + [ 178 + Html.a_class [ "jon-shell-sidebar"; "odoc-global-toc" ]; 179 + Html.a_id "sidebar-content"; 180 + ] 181 + []; 182 + ] 183 + else [] 184 + in 185 + 186 + let body = 187 + [ 188 + Html.header 189 + ~a:[ Html.a_class [ "jon-shell-header" ] ] 190 + [ 191 + Html.button 192 + ~a: 193 + [ 194 + Html.a_class [ "jon-shell-sidebar-toggle" ]; 195 + Html.a_title "Toggle sidebar"; 196 + ] 197 + [ Html.txt "\xe2\x98\xb0" ]; 198 + Html.a ~a:[ Html.a_href "/" ] [ Html.txt "jon.recoil.org" ]; 199 + Html.nav 200 + [ 201 + Html.a ~a:[ Html.a_href "/blog/" ] [ Html.txt "blog" ]; 202 + Html.a ~a:[ Html.a_href "/notebooks/" ] [ Html.txt "notebooks" ]; 203 + Html.a ~a:[ Html.a_href "/projects/" ] [ Html.txt "projects" ]; 204 + Html.a ~a:[ Html.a_href "/reference/" ] [ Html.txt "reference" ]; 205 + ]; 206 + ]; 207 + Html.main 208 + ~a:[ Html.a_class [ "jon-shell-main" ] ] 209 + (sidebar_nav 210 + @ [ 211 + Html.div 212 + ~a:[ Html.a_class [ "odoc-content" ] ] 213 + ((header :> Html_types.div_content Html.elt list) 214 + @ (preamble :> Html_types.div_content Html.elt list) 215 + @ content); 216 + ]); 217 + Html.footer 218 + ~a:[ Html.a_class [ "jon-shell-footer" ] ] 219 + [ Html.txt "jon ludlam" ]; 220 + ] 221 + @ [ 222 + Html.script 223 + ~a:[ Html.a_src shell_js_uri; Html.a_defer () ] 224 + (Html.txt ""); 225 + ] 226 + in 227 + 228 + let htmlpp = Html.pp ~indent:(Odoc_html.Config.indent config) () in 229 + let html = 230 + Html.html head (Html.body ~a:[ Html.a_class [ "odoc"; "jon-shell" ] ] body) 231 + in 232 + let content ppf = 233 + htmlpp ppf html; 234 + Format.pp_force_newline ppf () 235 + in 236 + content 237 + 238 + let make ~config ~url ~header ~preamble ~uses_katex ~resources ~sidebar_data 239 + ~assets content children = 240 + let filename = Odoc_html.Link.Path.as_filename ~config url in 241 + let content = 242 + page_creator ~config ~url ~uses_katex ~resources ~sidebar_data ~header 243 + ~preamble content 244 + in 245 + { Odoc_document.Renderer.filename; content; children; path = url; assets } 246 + 247 + let src_page_creator ~config ~url ~header ~sidebar_data:_ title content = 248 + let support_uri = Odoc_html.Config.support_uri config in 249 + let file_uri = file_uri ~config ~url in 250 + let shell_css_uri = file_uri support_uri "extensions/jon-shell.css" in 251 + let shell_js_uri = file_uri support_uri "extensions/jon-shell.js" in 252 + 253 + (* Compute BASE_URL and CURRENT_URL for SPA *) 254 + let base_url = 255 + let page = Url.Path.{ kind = `File; parent = None; name = "" } in 256 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 257 + in 258 + let current_url = 259 + let filename = Odoc_html.Link.Path.as_filename ~config url in 260 + Fpath.to_string filename 261 + in 262 + 263 + let title_string = Printf.sprintf "Source: %s" title in 264 + 265 + let head : Html_types.head Html.elt = 266 + let meta_elements = 267 + [ 268 + Html.meta ~a:[ Html.a_charset "utf-8" ] (); 269 + Html.meta 270 + ~a: 271 + [ 272 + Html.a_name "viewport"; 273 + Html.a_content "width=device-width, initial-scale=1"; 274 + ] 275 + (); 276 + Html.link ~rel:[ `Stylesheet ] ~href:shell_css_uri (); 277 + Html.script 278 + (Html.Unsafe.data 279 + (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" 280 + base_url current_url)); 281 + ] 282 + @ xocaml_meta_tags config 283 + in 284 + Html.head (Html.title (Html.txt title_string)) meta_elements 285 + in 286 + 287 + (* Only render the sidebar container for reference pages — 288 + JS will populate it from sidebar.json *) 289 + let sidebar_nav = 290 + if Astring.String.is_prefix ~affix:"reference/" current_url then 291 + [ 292 + Html.nav 293 + ~a: 294 + [ 295 + Html.a_class [ "jon-shell-sidebar"; "odoc-global-toc" ]; 296 + Html.a_id "sidebar-content"; 297 + ] 298 + []; 299 + ] 300 + else [] 301 + in 302 + 303 + let body = 304 + [ 305 + Html.header 306 + ~a:[ Html.a_class [ "jon-shell-header" ] ] 307 + [ 308 + Html.button 309 + ~a: 310 + [ 311 + Html.a_class [ "jon-shell-sidebar-toggle" ]; 312 + Html.a_title "Toggle sidebar"; 313 + ] 314 + [ Html.txt "\xe2\x98\xb0" ]; 315 + Html.a ~a:[ Html.a_href "/" ] [ Html.txt "jon.recoil.org" ]; 316 + Html.nav 317 + [ 318 + Html.a ~a:[ Html.a_href "/blog/" ] [ Html.txt "blog" ]; 319 + Html.a ~a:[ Html.a_href "/notebooks/" ] [ Html.txt "notebooks" ]; 320 + Html.a ~a:[ Html.a_href "/projects/" ] [ Html.txt "projects" ]; 321 + Html.a ~a:[ Html.a_href "/reference/" ] [ Html.txt "reference" ]; 322 + ]; 323 + ]; 324 + Html.main 325 + ~a:[ Html.a_class [ "jon-shell-main" ] ] 326 + (sidebar_nav 327 + @ [ 328 + Html.div 329 + ~a:[ Html.a_class [ "odoc-content" ] ] 330 + ((header :> Html_types.div_content Html.elt list) 331 + @ (content :> Html_types.div_content Html.elt list)); 332 + ]); 333 + Html.footer 334 + ~a:[ Html.a_class [ "jon-shell-footer" ] ] 335 + [ Html.txt "jon ludlam" ]; 336 + Html.script 337 + ~a:[ Html.a_src shell_js_uri; Html.a_defer () ] 338 + (Html.txt ""); 339 + ] 340 + in 341 + 342 + let htmlpp = Html.pp ~indent:false () in 343 + let html = 344 + Html.html head 345 + (Html.body ~a:[ Html.a_class [ "odoc-src"; "jon-shell" ] ] body) 346 + in 347 + let content ppf = 348 + htmlpp ppf html; 349 + Format.pp_force_newline ppf () 350 + in 351 + content 352 + 353 + let make_src ~config ~url ~header ~sidebar_data title content = 354 + let filename = Odoc_html.Link.Path.as_filename ~config url in 355 + let content = 356 + src_page_creator ~config ~url ~header ~sidebar_data title content 357 + in 358 + { 359 + Odoc_document.Renderer.filename; 360 + content; 361 + children = []; 362 + path = url; 363 + assets = []; 364 + } 365 + 366 + (* Register the shell *) 367 + let () = 368 + Odoc_html.Html_shell.register 369 + (module struct 370 + let name = "jon-shell" 371 + 372 + let make ~config (data : Odoc_html.Html_shell.page_data) = 373 + make ~config ~url:data.url ~header:data.header ~preamble:data.preamble 374 + ~uses_katex:data.uses_katex ~resources:data.resources 375 + ~sidebar_data:data.sidebar_data ~assets:data.assets data.content 376 + data.children 377 + 378 + let make_src ~config (data : Odoc_html.Html_shell.src_page_data) = 379 + make_src ~config ~url:data.url ~header:data.header 380 + ~sidebar_data:data.sidebar_data data.title data.content 381 + end) 382 + 383 + (* --- Metadata tag extensions --- 384 + 385 + Custom tags like @published, @notanotebook, and @packages are used as 386 + metadata for tooling (feed generation, blog indexing) but should not 387 + appear in the rendered HTML. We register extension handlers that 388 + suppress them by returning empty content. *) 389 + 390 + module Api = Odoc_extension_api 391 + 392 + let hidden_tag_extension prefix = 393 + let module E = struct 394 + let prefix = prefix 395 + let to_document ~tag:_ _content = 396 + Api.simple_output [] 397 + end in 398 + Api.Registry.register (module E) 399 + 400 + let () = 401 + List.iter hidden_tag_extension [ "published"; "notanotebook"; "packages" ] 402 + 403 + (* --- Recent posts extension --- *) 404 + 405 + module Recent_posts = struct 406 + open Odoc_document.Types 407 + 408 + let prefix = "recent-posts" 409 + 410 + (* Check if a string matches YYYY-MM-DD date pattern *) 411 + let is_date s = 412 + let s = Stdlib.String.trim s in 413 + Stdlib.String.length s = 10 414 + && s.[4] = '-' && s.[7] = '-' 415 + && (try 416 + let _ = int_of_string (Stdlib.String.sub s 0 4) in 417 + let _ = int_of_string (Stdlib.String.sub s 5 2) in 418 + let _ = int_of_string (Stdlib.String.sub s 8 2) in 419 + true 420 + with _ -> false) 421 + 422 + (* Format "2025-12-15" -> "Dec 2025" *) 423 + let format_date s = 424 + let s = Stdlib.String.trim s in 425 + let year = Stdlib.String.sub s 0 4 in 426 + let month = Stdlib.String.sub s 5 2 in 427 + let m = match month with 428 + | "01" -> "Jan" | "02" -> "Feb" | "03" -> "Mar" | "04" -> "Apr" 429 + | "05" -> "May" | "06" -> "Jun" | "07" -> "Jul" | "08" -> "Aug" 430 + | "09" -> "Sep" | "10" -> "Oct" | "11" -> "Nov" | "12" -> "Dec" 431 + | _ -> month 432 + in 433 + m ^ " " ^ year 434 + 435 + (* Find the first unordered list in Block.t *) 436 + let find_list (blocks : Block.t) = 437 + List.find_map (fun (b : Block.one) -> 438 + match b.desc with 439 + | List (Unordered, items) -> Some items 440 + | _ -> None 441 + ) blocks 442 + 443 + (* Extract the link inline element from a list of inlines *) 444 + let find_link (inlines : Inline.t) = 445 + List.find_map (fun (i : Inline.one) -> 446 + match i.desc with 447 + | Link _ -> Some i 448 + | _ -> None 449 + ) inlines 450 + 451 + (* Extract date string from trailing text in inlines *) 452 + let find_date (inlines : Inline.t) = 453 + let texts = List.filter_map (fun (i : Inline.one) -> 454 + match i.desc with 455 + | Text s when is_date s -> Some (Stdlib.String.trim s) 456 + | _ -> None 457 + ) inlines in 458 + match texts with 459 + | [] -> None 460 + | _ -> Some (List.hd (List.rev texts)) 461 + 462 + (* Extract link and date from inlines *) 463 + let extract_from_inlines inlines = 464 + match find_link inlines, find_date inlines with 465 + | Some link, Some date -> Some (link, date) 466 + | Some link, None -> Some (link, "") 467 + | _ -> None 468 + 469 + (* Extract link and date from a list item (Block.t) *) 470 + let extract_item (item : Block.t) = 471 + List.find_map (fun (b : Block.one) -> 472 + match b.desc with 473 + | Paragraph inlines | Inline inlines -> extract_from_inlines inlines 474 + | _ -> None 475 + ) item 476 + 477 + (* Extract an optional excerpt Block.t from a list item. 478 + The link phase injects synopsis paragraphs as extra elements in list items. 479 + We look for a second paragraph (after the one containing the link/date). *) 480 + let extract_excerpt (item : Block.t) = 481 + let paragraphs = List.filter (fun (b : Block.one) -> 482 + match b.desc with 483 + | Paragraph _ -> true 484 + | _ -> false 485 + ) item in 486 + match paragraphs with 487 + | _ :: excerpt :: _ -> Some excerpt 488 + | _ -> None 489 + 490 + let raw html = Block.{ attr = []; desc = Raw_markup ("html", html) } 491 + 492 + let inline_block (inlines : Inline.t) = 493 + Block.{ attr = []; desc = Inline inlines } 494 + 495 + let recent_posts_css = {| 496 + /* Recent posts extension - neutralize at-tags wrapper */ 497 + .jon-shell-main ul.at-tags:has(li.recent-posts) { 498 + margin: 0; 499 + padding: 0; 500 + list-style: none; 501 + margin-left: 0; 502 + } 503 + .jon-shell-main .at-tags li.recent-posts { 504 + list-style: none; 505 + padding: 0; 506 + padding-left: 0; 507 + margin: 0; 508 + text-indent: 0; 509 + } 510 + .recent-posts { 511 + margin: 1.5rem 0 2rem; 512 + } 513 + .recent-posts-header { 514 + display: flex; 515 + align-items: baseline; 516 + justify-content: space-between; 517 + margin-bottom: 1.25rem; 518 + } 519 + .recent-posts-title { 520 + font-size: 1.35rem; 521 + font-weight: 500; 522 + color: var(--text-color); 523 + letter-spacing: -0.01em; 524 + } 525 + .recent-posts-link { 526 + font-size: 0.85rem; 527 + color: var(--text-muted); 528 + } 529 + .recent-posts-link:hover { 530 + color: var(--link-color); 531 + } 532 + .recent-posts-featured { 533 + background: var(--surface-color, #fff); 534 + border: 1px solid var(--border-color); 535 + border-radius: 10px; 536 + padding: 1.5rem 1.75rem; 537 + margin-bottom: 1.25rem; 538 + transition: box-shadow 0.2s ease; 539 + } 540 + .recent-posts-featured:hover { 541 + box-shadow: 0 2px 12px rgba(0,0,0,0.06); 542 + } 543 + .recent-posts-featured-label { 544 + font-size: 0.72rem; 545 + font-weight: 600; 546 + letter-spacing: 0.08em; 547 + text-transform: uppercase; 548 + color: var(--accent-color, #b44e2d); 549 + margin-bottom: 0.5rem; 550 + } 551 + .recent-posts-featured-title { 552 + font-size: 1.35rem; 553 + font-weight: 500; 554 + line-height: 1.3; 555 + margin-bottom: 0.5rem; 556 + } 557 + .recent-posts-featured-title a { 558 + color: var(--text-color); 559 + text-decoration: none; 560 + } 561 + .recent-posts-featured-title a:hover { 562 + color: var(--accent-color, #b44e2d); 563 + } 564 + .recent-posts-featured-date { 565 + font-size: 0.8rem; 566 + color: var(--text-muted); 567 + } 568 + .recent-posts-featured-excerpt { 569 + font-size: 0.92rem; 570 + line-height: 1.55; 571 + color: var(--text-muted); 572 + margin: 0.75rem 0 0; 573 + display: -webkit-box; 574 + -webkit-line-clamp: 3; 575 + -webkit-box-orient: vertical; 576 + overflow: hidden; 577 + } 578 + .recent-posts-list { 579 + display: flex; 580 + flex-direction: column; 581 + gap: 1px; 582 + background: var(--border-color); 583 + border-radius: 10px; 584 + overflow: hidden; 585 + border: 1px solid var(--border-color); 586 + } 587 + .recent-posts-item { 588 + display: grid; 589 + grid-template-columns: 1fr auto; 590 + gap: 1rem; 591 + align-items: baseline; 592 + padding: 0.875rem 1.25rem; 593 + background: var(--surface-color, #fff); 594 + transition: background 0.15s ease; 595 + } 596 + .recent-posts-item:hover { 597 + background: var(--bg-hover, #f0f2f5); 598 + } 599 + .recent-posts-item-title { 600 + font-size: 1rem; 601 + font-weight: 400; 602 + line-height: 1.4; 603 + } 604 + .recent-posts-item-title a { 605 + color: var(--text-color); 606 + text-decoration: none; 607 + } 608 + .recent-posts-item-title a:hover { 609 + color: var(--accent-color, #b44e2d); 610 + } 611 + .recent-posts-item-date { 612 + font-size: 0.78rem; 613 + color: var(--text-muted); 614 + white-space: nowrap; 615 + font-variant-numeric: tabular-nums; 616 + } 617 + @media (prefers-color-scheme: dark) { 618 + .recent-posts-featured { 619 + background: var(--surface-color, #22242a); 620 + } 621 + .recent-posts-featured:hover { 622 + box-shadow: 0 2px 12px rgba(0,0,0,0.2); 623 + } 624 + .recent-posts-item { 625 + background: var(--surface-color, #22242a); 626 + } 627 + .recent-posts-item:hover { 628 + background: var(--bg-hover, #2a2d35); 629 + } 630 + } 631 + @media (max-width: 700px) { 632 + .recent-posts-item { 633 + grid-template-columns: 1fr; 634 + gap: 0.25rem; 635 + } 636 + .recent-posts-featured { 637 + padding: 1.25rem; 638 + } 639 + } 640 + |} 641 + 642 + module Identifier = Odoc_model.Paths.Identifier 643 + 644 + (* Build a path hierarchy from a page identifier, for use with Env.lookup_page_by_path. 645 + E.g. LeafPage(Page(Page(Page(None, "jon-site"), "blog"), "2026"), "weeknotes-2026-09") 646 + -> (`TAbsolutePath, ["jon-site"; "blog"; "2026"; "weeknotes-2026-09"]) *) 647 + let rec hierarchy_of_page_id (id : Identifier.Page.t) : string list = 648 + match id.iv with 649 + | `Page (Some parent, name) | `LeafPage (Some parent, name) -> 650 + hierarchy_of_container_id parent @ [Odoc_model.Names.PageName.to_string name] 651 + | `Page (None, name) | `LeafPage (None, name) -> 652 + [Odoc_model.Names.PageName.to_string name] 653 + 654 + and hierarchy_of_container_id (id : Identifier.ContainerPage.t) : string list = 655 + match id.iv with 656 + | `Page (Some parent, name) -> 657 + hierarchy_of_container_id parent @ [Odoc_model.Names.PageName.to_string name] 658 + | `Page (None, name) -> 659 + [Odoc_model.Names.PageName.to_string name] 660 + 661 + (* Extract page identifier from a resolved reference in a list item's content *) 662 + let extract_page_id_from_item 663 + (item : Api.Comment.nestable_block_element Api.Location_.with_location list) = 664 + List.find_map (fun (el : Api.Comment.nestable_block_element Api.Location_.with_location) -> 665 + match el.Api.Location_.value with 666 + | `Paragraph inlines -> 667 + List.find_map (fun (inline : Api.Comment.inline_element Api.Location_.with_location) -> 668 + match inline.Api.Location_.value with 669 + | `Reference (`Resolved r, _) -> ( 670 + match Odoc_model.Paths.Reference.Resolved.identifier r with 671 + | Some id -> ( 672 + match (id : Identifier.t).iv with 673 + | `LeafPage _ as iv -> 674 + Some ({ iv; ihash = id.ihash; ikey = id.ikey } : Identifier.Page.t) 675 + | `Page _ as iv -> 676 + Some ({ iv; ihash = id.ihash; ikey = id.ikey } : Identifier.Page.t) 677 + | _ -> None) 678 + | None -> None) 679 + | _ -> None 680 + ) inlines 681 + | _ -> None 682 + ) item 683 + 684 + (* Link phase: enrich list items with page synopses from the environment. 685 + For each list item that contains a page reference, look up the page 686 + and inject its synopsis as an additional paragraph. *) 687 + let link ~tag:_ env content = 688 + let enrich_item item = 689 + match extract_page_id_from_item item with 690 + | None -> item 691 + | Some page_id -> 692 + let segments = hierarchy_of_page_id page_id in 693 + let hierarchy : Odoc_model.Paths.Reference.Hierarchy.t = 694 + (`TCurrentPackage, segments) 695 + in 696 + (match Api.Env.lookup_page_by_path hierarchy env with 697 + | Error _ -> item 698 + | Ok page -> 699 + (* Find first paragraph, skipping headings and tags *) 700 + let synopsis = 701 + List.find_map (fun (el : Odoc_model.Comment.block_element Api.Location_.with_location) -> 702 + match el.Api.Location_.value with 703 + | `Paragraph p -> Some p 704 + | `Heading _ | `Tag _ -> None 705 + | _ -> None 706 + ) page.content.elements 707 + in 708 + match synopsis with 709 + | None -> item 710 + | Some synopsis_paragraph -> 711 + let dummy_loc = { 712 + Api.Location_.file = ""; start = { line = 0; column = 0 }; 713 + end_ = { line = 0; column = 0 } 714 + } in 715 + item @ [ { Api.Location_.value = `Paragraph synopsis_paragraph; 716 + location = dummy_loc } ]) 717 + in 718 + List.map (fun (el : Api.Comment.nestable_block_element Api.Location_.with_location) -> 719 + match el.Api.Location_.value with 720 + | `List (kind, items) -> 721 + let enriched_items = List.map enrich_item items in 722 + { el with value = `List (kind, enriched_items) } 723 + | _ -> el 724 + ) content 725 + 726 + let to_document ~tag:_ content = 727 + let blocks = Api.blocks_of_nestable_elements content in 728 + match find_list blocks with 729 + | None -> Api.simple_output blocks 730 + | Some items -> 731 + let parsed = List.filter_map extract_item items in 732 + (* Extract excerpts from the first list item (featured) *) 733 + let featured_excerpt = match items with 734 + | first_item :: _ -> extract_excerpt first_item 735 + | [] -> None 736 + in 737 + match parsed with 738 + | [] -> Api.simple_output blocks 739 + | (featured_link, featured_date) :: rest -> 740 + let result = ref [] in 741 + let add b = result := b :: !result in 742 + (* Section open + header *) 743 + add (raw {|<div class="recent-posts"><div class="recent-posts-header"><span class="recent-posts-title">Recent writing</span><a href="/blog/" class="recent-posts-link">All posts &rarr;</a></div>|}); 744 + (* Featured card *) 745 + add (raw {|<div class="recent-posts-featured"><div class="recent-posts-featured-label">Featured</div><div class="recent-posts-featured-title">|}); 746 + add (inline_block [featured_link]); 747 + let date_html = 748 + if featured_date = "" then "" 749 + else Printf.sprintf {|<div class="recent-posts-featured-date">%s</div>|} (format_date featured_date) 750 + in 751 + add (raw (Printf.sprintf {|</div>%s|} date_html)); 752 + (* Excerpt from referenced page *) 753 + (match featured_excerpt with 754 + | Some excerpt_block -> 755 + (match excerpt_block.desc with 756 + | Paragraph inlines -> 757 + add (raw {|<p class="recent-posts-featured-excerpt">|}); 758 + add (inline_block inlines); 759 + add (raw {|</p>|}) 760 + | _ -> ()) 761 + | None -> ()); 762 + add (raw {|</div>|}); 763 + (* Post list *) 764 + if rest <> [] then begin 765 + add (raw {|<div class="recent-posts-list">|}); 766 + List.iter (fun (link, date) -> 767 + add (raw {|<div class="recent-posts-item"><div class="recent-posts-item-title">|}); 768 + add (inline_block [link]); 769 + let date_str = if date = "" then "" else format_date date in 770 + add (raw (Printf.sprintf {|</div><span class="recent-posts-item-date">%s</span></div>|} date_str)) 771 + ) rest; 772 + add (raw {|</div>|}) 773 + end; 774 + (* Section close *) 775 + add (raw {|</div>|}); 776 + { 777 + content = List.rev !result; 778 + overrides = []; 779 + resources = [Css_inline recent_posts_css]; 780 + assets = []; 781 + } 782 + end 783 + 784 + let () = Api.Registry.register_with_link (module Recent_posts)
+788
odoc-jons-plugins/src/odoc_jons_plugins_css.ml
··· 1 + let css = 2 + {| 3 + :root { 4 + --max-width: 700px; 5 + --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 6 + "Helvetica Neue", Arial, sans-serif; 7 + --font-mono: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; 8 + --bg-color: #ffffff; 9 + --text-color: #1a1a2e; 10 + --text-muted: #6b7280; 11 + --link-color: #b44e2d; 12 + --link-hover: #943f24; 13 + --border-color: #e5e7eb; 14 + --code-bg: #f6f8fa; 15 + --code-border: #e5e7eb; 16 + --header-bg: #ffffff; 17 + --highlight-bg: rgba(180, 78, 45, 0.08); 18 + 19 + /* x-ocaml interactive cells */ 20 + --xo-font-size: 0.875rem; 21 + --xo-bg: var(--code-bg); 22 + --xo-text: var(--text-color); 23 + --xo-gutter-bg: var(--code-bg); 24 + --xo-gutter-text: var(--text-muted); 25 + --xo-gutter-border: var(--border-color); 26 + --xo-stdout-bg: rgba(180, 78, 45, 0.06); 27 + --xo-stdout-text: var(--link-color); 28 + --xo-stderr-bg: rgba(218, 9, 9, 0.06); 29 + --xo-stderr-text: #cf222e; 30 + --xo-meta-bg: var(--code-bg); 31 + --xo-meta-text: var(--text-muted); 32 + --xo-tooltip-bg: var(--bg-color); 33 + --xo-tooltip-text: var(--text-color); 34 + --xo-tooltip-border: var(--border-color); 35 + --xo-btn-bg: var(--code-bg); 36 + --xo-btn-border: var(--border-color); 37 + --xo-btn-text: var(--text-muted); 38 + --xo-btn-hover-bg: var(--text-muted); 39 + --xo-btn-hover-text: var(--bg-color); 40 + } 41 + 42 + @media (prefers-color-scheme: dark) { 43 + :root { 44 + --bg-color: #0d1117; 45 + --text-color: #e6edf3; 46 + --text-muted: #8b949e; 47 + --link-color: #e07850; 48 + --link-hover: #f09070; 49 + --border-color: #30363d; 50 + --code-bg: #161b22; 51 + --code-border: #30363d; 52 + --header-bg: #161b22; 53 + --highlight-bg: rgba(224, 120, 80, 0.12); 54 + 55 + /* x-ocaml interactive cells - dark overrides */ 56 + --xo-stdout-bg: rgba(88, 166, 255, 0.08); 57 + --xo-stdout-text: #79c0ff; 58 + --xo-stderr-bg: rgba(248, 81, 73, 0.08); 59 + --xo-stderr-text: #f85149; 60 + } 61 + } 62 + 63 + /* Reset */ 64 + * { 65 + box-sizing: border-box; 66 + margin: 0; 67 + padding: 0; 68 + } 69 + 70 + body { 71 + font-family: var(--font-body); 72 + font-size: 16px; 73 + line-height: 1.7; 74 + color: var(--text-color); 75 + background: var(--bg-color); 76 + } 77 + 78 + a { 79 + color: var(--link-color); 80 + text-decoration: none; 81 + } 82 + 83 + a:hover { 84 + color: var(--link-hover); 85 + text-decoration: underline; 86 + } 87 + 88 + /* Header */ 89 + .jon-shell-header { 90 + display: flex; 91 + align-items: center; 92 + gap: 12px; 93 + max-width: calc(var(--max-width) + 300px); 94 + margin: 0 auto; 95 + padding: 16px 20px; 96 + font-size: 14px; 97 + border-bottom: 1px solid var(--border-color); 98 + } 99 + 100 + .jon-shell-header > a { 101 + font-weight: 600; 102 + color: var(--text-color); 103 + text-decoration: none; 104 + } 105 + 106 + .jon-shell-header > a:hover { 107 + color: var(--link-color); 108 + } 109 + 110 + .jon-shell-header nav { 111 + display: flex; 112 + gap: 20px; 113 + margin-left: auto; 114 + } 115 + 116 + .jon-shell-header nav a { 117 + color: var(--text-muted); 118 + text-decoration: none; 119 + } 120 + 121 + .jon-shell-header nav a:hover { 122 + color: var(--link-color); 123 + } 124 + 125 + /* Main content */ 126 + .jon-shell-main { 127 + max-width: calc(var(--max-width) + 300px); 128 + margin: 0 auto; 129 + padding: 24px 20px 60px; 130 + display: flex; 131 + gap: 32px; 132 + } 133 + 134 + .jon-shell-main .odoc-content { 135 + flex: 1; 136 + min-width: 0; 137 + } 138 + 139 + /* Sidebar */ 140 + .jon-shell-sidebar { 141 + width: 260px; 142 + flex-shrink: 0; 143 + font-size: 0.85rem; 144 + line-height: 1.5; 145 + position: sticky; 146 + top: 24px; 147 + align-self: flex-start; 148 + max-height: calc(100vh - 48px); 149 + overflow-y: auto; 150 + } 151 + .jon-shell-sidebar:empty { 152 + display: none; 153 + } 154 + 155 + /* Remove indentation from the top-level sidebar list */ 156 + 157 + .jon-shell-sidebar ul ul { 158 + padding-left: 12px; 159 + margin-left: 4px; 160 + border-left: 1px solid var(--border-color); 161 + } 162 + 163 + .jon-shell-sidebar li { 164 + margin: 0; 165 + } 166 + 167 + .jon-shell-sidebar a, 168 + .jon-shell-sidebar .sidebar-label { 169 + display: block; 170 + padding: 2px 8px; 171 + color: var(--text-muted); 172 + text-decoration: none; 173 + border-radius: 4px; 174 + } 175 + 176 + .jon-shell-sidebar a:hover { 177 + color: var(--link-color); 178 + background: var(--highlight-bg); 179 + text-decoration: none; 180 + } 181 + 182 + .jon-shell-sidebar a.current_unit { 183 + color: var(--link-color); 184 + font-weight: 600; 185 + background: none; 186 + } 187 + 188 + /* Collapsible entries — small inline chevron before the link */ 189 + .jon-shell-sidebar .sidebar-toggle { 190 + display: inline-flex; 191 + align-items: center; 192 + justify-content: center; 193 + width: 20px; 194 + height: 20px; 195 + cursor: pointer; 196 + color: var(--text-muted); 197 + opacity: 0.55; 198 + font-size: 1em; 199 + vertical-align: middle; 200 + flex-shrink: 0; 201 + transition: opacity 0.15s, transform 0.15s; 202 + user-select: none; 203 + } 204 + 205 + .jon-shell-sidebar .sidebar-toggle:hover { 206 + opacity: 0.8; 207 + } 208 + 209 + .jon-shell-sidebar .sidebar-toggle::before { 210 + content: "\25B8"; 211 + display: block; 212 + transition: transform 0.15s; 213 + } 214 + 215 + .jon-shell-sidebar li:not(.collapsed) > .sidebar-toggle::before { 216 + transform: rotate(90deg); 217 + } 218 + 219 + .jon-shell-sidebar li.collapsed > ul { 220 + display: none; 221 + } 222 + 223 + /* Items with children: put toggle and link on same line */ 224 + .jon-shell-sidebar li:has(> .sidebar-toggle) { 225 + display: flex; 226 + flex-wrap: wrap; 227 + align-items: baseline; 228 + } 229 + 230 + .jon-shell-sidebar li:has(> .sidebar-toggle) > a, 231 + .jon-shell-sidebar li:has(> .sidebar-toggle) > .sidebar-label { 232 + flex: 1; 233 + min-width: 0; 234 + } 235 + 236 + .jon-shell-sidebar li:has(> .sidebar-toggle) > ul { 237 + flex-basis: 100%; 238 + } 239 + 240 + /* Items without children: indent to align with toggle items */ 241 + .jon-shell-sidebar li:not(:has(> .sidebar-toggle)) > a, 242 + .jon-shell-sidebar li:not(:has(> .sidebar-toggle)) > .sidebar-label { 243 + margin-left: 20px; 244 + } 245 + 246 + /* Ensure collapsed children are fully hidden including overflow */ 247 + .jon-shell-sidebar li.collapsed > ul { 248 + display: none !important; 249 + } 250 + 251 + /* Hide the bare "index" text breadcrumb from odoc sidebar */ 252 + .jon-shell-sidebar > ul > li:last-child:not(:has(a)) { 253 + display: none; 254 + } 255 + 256 + /* Collapse the top-level wrapper padding so content aligns with header */ 257 + 258 + /* Group headings in the sidebar — static section headers, not collapsible */ 259 + .jon-shell-sidebar .sidebar-group { 260 + display: block; 261 + margin-top: 1rem; 262 + } 263 + .jon-shell-sidebar .sidebar-group:first-child { 264 + margin-top: 0; 265 + } 266 + .jon-shell-sidebar .sidebar-group-heading { 267 + display: block; 268 + padding: 2px 8px; 269 + font-size: 0.75rem; 270 + font-weight: 600; 271 + letter-spacing: 0.04em; 272 + text-transform: uppercase; 273 + color: var(--text-muted); 274 + opacity: 0.7; 275 + margin-bottom: 2px; 276 + } 277 + .jon-shell-sidebar .sidebar-group > ul { 278 + padding-left: 0; 279 + margin-left: 0; 280 + border-left: none; 281 + } 282 + 283 + /* Hero intro - side-by-side text + photo */ 284 + .hero-intro { 285 + display: grid; 286 + grid-template-columns: 1fr auto; 287 + gap: 2rem; 288 + align-items: start; 289 + margin-bottom: 0.5rem; 290 + } 291 + .hero-intro .hero-text { 292 + min-width: 0; 293 + } 294 + .hero-intro .hero-text > p { 295 + margin-top: 0; 296 + } 297 + .hero-photo { 298 + width: 120px; 299 + height: 120px; 300 + border-radius: 50%; 301 + object-fit: cover; 302 + box-shadow: 0 0 0 1px var(--border-color); 303 + margin-top: 4px; 304 + } 305 + @media (max-width: 700px) { 306 + .hero-intro { 307 + grid-template-columns: 1fr; 308 + gap: 1rem; 309 + } 310 + .hero-intro .hero-photo { 311 + order: -1; 312 + } 313 + } 314 + 315 + /* Typography */ 316 + .jon-shell-main h1 { 317 + font-size: 2rem; 318 + font-weight: 700; 319 + line-height: 1.2; 320 + margin-bottom: 24px; 321 + } 322 + 323 + .jon-shell-main h2 { 324 + font-size: 1.5rem; 325 + font-weight: 600; 326 + margin-top: 40px; 327 + margin-bottom: 16px; 328 + } 329 + 330 + .jon-shell-main h3 { 331 + font-size: 1.25rem; 332 + font-weight: 600; 333 + margin-top: 32px; 334 + margin-bottom: 12px; 335 + } 336 + 337 + .jon-shell-main h4, 338 + .jon-shell-main h5, 339 + .jon-shell-main h6 { 340 + font-size: 1.1rem; 341 + font-weight: 600; 342 + margin-top: 24px; 343 + margin-bottom: 8px; 344 + } 345 + 346 + .jon-shell-main p { 347 + margin-bottom: 16px; 348 + } 349 + 350 + .jon-shell-main ul, 351 + .jon-shell-main ol { 352 + margin-bottom: 16px; 353 + padding-left: 28px; 354 + } 355 + 356 + /* Sidebar lists reset — must come after .jon-shell-main ul to win cascade */ 357 + .jon-shell-sidebar ul { 358 + list-style: none; 359 + padding: 0; 360 + margin: 0; 361 + } 362 + 363 + .jon-shell-main li { 364 + margin-bottom: 4px; 365 + } 366 + 367 + .jon-shell-main blockquote { 368 + border-left: 3px solid var(--border-color); 369 + margin: 16px 0; 370 + padding: 8px 16px; 371 + color: var(--text-muted); 372 + } 373 + 374 + .jon-shell-main table { 375 + width: 100%; 376 + border-collapse: collapse; 377 + margin-bottom: 16px; 378 + font-size: 0.95em; 379 + } 380 + 381 + .jon-shell-main th, 382 + .jon-shell-main td { 383 + padding: 10px 12px; 384 + border: 1px solid var(--border-color); 385 + text-align: left; 386 + } 387 + 388 + .jon-shell-main th { 389 + background: var(--code-bg); 390 + font-weight: 600; 391 + } 392 + 393 + /* Code */ 394 + .jon-shell-main code { 395 + font-family: var(--font-mono); 396 + font-size: 0.88em; 397 + background: var(--code-bg); 398 + padding: 2px 6px; 399 + border-radius: 4px; 400 + border: 1px solid var(--code-border); 401 + } 402 + 403 + .jon-shell-main pre { 404 + background: var(--code-bg); 405 + border: 1px solid var(--code-border); 406 + border-radius: 6px; 407 + padding: 16px; 408 + overflow-x: auto; 409 + margin-bottom: 16px; 410 + } 411 + 412 + .jon-shell-main pre code { 413 + background: none; 414 + border: none; 415 + padding: 0; 416 + font-size: 0.875rem; 417 + line-height: 1.5; 418 + } 419 + 420 + /* odoc specifics */ 421 + .odoc-spec { 422 + margin: 16px 0; 423 + padding: 12px 16px; 424 + background: var(--code-bg); 425 + border: 1px solid var(--code-border); 426 + border-radius: 6px; 427 + border-left: 3px solid var(--link-color); 428 + } 429 + 430 + .odoc-spec code { 431 + background: none; 432 + border: none; 433 + padding: 0; 434 + } 435 + 436 + .spec { 437 + font-family: var(--font-mono); 438 + font-size: 0.9rem; 439 + } 440 + 441 + .spec-doc { 442 + margin-top: 8px; 443 + padding-top: 8px; 444 + border-top: 1px solid var(--border-color); 445 + font-size: 0.95rem; 446 + } 447 + 448 + .comment-delim { 449 + display: none; 450 + } 451 + 452 + .odoc-include { 453 + margin: 16px 0; 454 + padding: 12px; 455 + border: 1px solid var(--border-color); 456 + border-left: 3px solid var(--text-muted); 457 + border-radius: 6px; 458 + } 459 + 460 + .odoc-include > details > summary { 461 + cursor: pointer; 462 + font-family: var(--font-mono); 463 + font-size: 0.9rem; 464 + } 465 + 466 + /* Source links float right inside spec blocks and headings */ 467 + a.source_link { 468 + float: right; 469 + color: var(--text-muted); 470 + font-family: var(--font-body); 471 + font-size: 0.8rem; 472 + font-weight: normal; 473 + } 474 + 475 + a.source_link:hover { 476 + color: var(--link-color); 477 + } 478 + 479 + /* Source code pages */ 480 + .source_container { 481 + display: flex; 482 + margin-top: 0; 483 + font-family: var(--font-mono); 484 + font-size: 0.85rem; 485 + line-height: 1.4; 486 + background: var(--code-bg); 487 + border: 1px solid var(--code-border); 488 + border-radius: 6px; 489 + overflow-x: auto; 490 + } 491 + 492 + .source_line_column { 493 + padding: 12px 0; 494 + text-align: right; 495 + color: var(--text-muted); 496 + background: var(--code-bg); 497 + border-right: 1px solid var(--code-border); 498 + user-select: none; 499 + } 500 + 501 + .source_line { 502 + padding: 0 12px; 503 + } 504 + 505 + .source_code { 506 + flex-grow: 1; 507 + padding: 12px 16px; 508 + color: var(--text-color); 509 + overflow-x: auto; 510 + } 511 + 512 + .source_code pre { 513 + margin: 0; 514 + background: none; 515 + border: none; 516 + padding: 0; 517 + } 518 + 519 + .source_code code { 520 + background: none; 521 + border: none; 522 + padding: 0; 523 + font-size: inherit; 524 + } 525 + 526 + .odoc-src pre a { 527 + color: inherit; 528 + } 529 + 530 + /* Source directory listings */ 531 + .odoc-directory::before { 532 + content: "\01F4C1"; 533 + margin-right: 0.3em; 534 + } 535 + 536 + /* Source code syntax highlighting */ 537 + :root { 538 + --src-keyword: #cf222e; 539 + --src-uident: #0550ae; 540 + --src-lident: var(--text-color); 541 + --src-literal: #0a3069; 542 + --src-comment: var(--text-muted); 543 + --src-docstring: #116329; 544 + --src-separator: #953800; 545 + --src-parens: #953800; 546 + --src-operator: #8250df; 547 + --src-underscore: var(--text-muted); 548 + } 549 + 550 + @media (prefers-color-scheme: dark) { 551 + :root { 552 + --src-keyword: #ff7b72; 553 + --src-uident: #79c0ff; 554 + --src-lident: var(--text-color); 555 + --src-literal: #a5d6ff; 556 + --src-comment: var(--text-muted); 557 + --src-docstring: #7ee787; 558 + --src-separator: #d29922; 559 + --src-parens: #d29922; 560 + --src-operator: #d2a8ff; 561 + --src-underscore: var(--text-muted); 562 + } 563 + } 564 + 565 + /* Keywords */ 566 + .AND, .ANDOP, .AS, .ASSERT, 567 + .BAR, .BEGIN, 568 + .CLASS, .CONSTRAINT, 569 + .DO, .DONE, .DOWNTO, 570 + .ELSE, .END, .EXCEPTION, .EXTERNAL, 571 + .FOR, .FUN, .FUNCTION, .FUNCTOR, 572 + .IF, .IN, .INCLUDE, .INHERIT, .INITIALIZER, 573 + .LAZY, .LESSMINUS, .LET, .LETOP, 574 + .MATCH, .METHOD, .MINUSGREATER, .MODULE, .MUTABLE, 575 + .NEW, .NONREC, 576 + .OBJECT, .OF, .OPEN, 577 + .PERCENT, .PRIVATE, 578 + .REC, 579 + .SEMISEMI, .SIG, .STRUCT, 580 + .THEN, .TO, .TRY, .TYPE, 581 + .VAL, .VIRTUAL, 582 + .WHEN, .WITH, .WHILE { 583 + color: var(--src-keyword); 584 + } 585 + 586 + /* Separators */ 587 + .COMMA, .COLON, .COLONGREATER, .SEMI { 588 + color: var(--src-separator); 589 + } 590 + 591 + /* Parens */ 592 + .BARRBRACKET, 593 + .LBRACE, .LBRACELESS, 594 + .LBRACKET, .LBRACKETAT, .LBRACKETATAT, .LBRACKETATATAT, 595 + .LBRACKETBAR, .LBRACKETGREATER, .LBRACKETLESS, 596 + .LBRACKETPERCENT, .LBRACKETPERCENTPERCENT, 597 + .LPAREN, .RBRACE, .RBRACKET, .RPAREN { 598 + color: var(--src-parens); 599 + } 600 + 601 + /* Operators */ 602 + .BANG, .PREFIXOP, 603 + .INFIXOP0, .INFIXOP1, .INFIXOP2, .INFIXOP3, .INFIXOP4, 604 + .BARBAR, .PLUS, .STAR, .AMPERAMPER, .AMPERAND, .COLONEQUAL, 605 + .GREATER, .LESS, .MINUS, .MINUSDOT, .MINUSGREATER, 606 + .OR, .PLUSDOT, .PLUSEQ, .EQUAL { 607 + color: var(--src-operator); 608 + } 609 + 610 + /* Upper case idents */ 611 + .UIDENT, .COLONCOLON, .TRUE, .FALSE { 612 + color: var(--src-uident); 613 + } 614 + 615 + /* Lower case idents */ 616 + .LIDENT, .QUESTION, .QUOTE, .TILDE { 617 + color: var(--src-lident); 618 + } 619 + 620 + /* Literals */ 621 + .STRING, .CHAR, .INT, .FLOAT, .QUOTED_STRING_EXPR, .QUOTED_STRING_ITEM { 622 + color: var(--src-literal); 623 + } 624 + 625 + .UNDERSCORE { 626 + color: var(--src-underscore); 627 + } 628 + 629 + .DOCSTRING { 630 + color: var(--src-docstring); 631 + } 632 + 633 + .COMMENT { 634 + color: var(--src-comment); 635 + font-style: italic; 636 + } 637 + 638 + .anchor { 639 + color: var(--text-muted); 640 + text-decoration: none; 641 + margin-left: 4px; 642 + opacity: 0; 643 + transition: opacity 0.15s; 644 + } 645 + 646 + h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, 647 + h4:hover .anchor, h5:hover .anchor, h6:hover .anchor, 648 + .spec:hover .anchor { 649 + opacity: 1; 650 + } 651 + 652 + .anchor:hover { 653 + color: var(--link-color); 654 + } 655 + 656 + :target { 657 + background: var(--highlight-bg); 658 + border-radius: 4px; 659 + } 660 + 661 + /* Footer */ 662 + .jon-shell-footer { 663 + max-width: calc(var(--max-width) + 300px); 664 + margin: 0 auto; 665 + padding: 24px 20px; 666 + border-top: 1px solid var(--border-color); 667 + font-size: 14px; 668 + color: var(--text-muted); 669 + } 670 + 671 + /* Responsive */ 672 + @media (max-width: 800px) { 673 + .jon-shell-sidebar { 674 + display: none; 675 + } 676 + 677 + .jon-shell-main { 678 + max-width: var(--max-width); 679 + } 680 + 681 + .jon-shell-header { 682 + max-width: var(--max-width); 683 + } 684 + 685 + .jon-shell-footer { 686 + max-width: var(--max-width); 687 + } 688 + } 689 + 690 + @media (max-width: 600px) { 691 + body { 692 + font-size: 16px; 693 + } 694 + 695 + .jon-shell-header { 696 + flex-direction: column; 697 + gap: 8px; 698 + align-items: flex-start; 699 + } 700 + 701 + .jon-shell-main h1 { 702 + font-size: 1.6rem; 703 + } 704 + } 705 + /* Sidebar toggle button */ 706 + .jon-shell-sidebar-toggle { 707 + background: none; 708 + border: 1px solid var(--border-color); 709 + border-radius: 4px; 710 + color: var(--text-muted); 711 + font-size: 14px; 712 + cursor: pointer; 713 + padding: 2px 6px; 714 + line-height: 1; 715 + transition: color 0.15s, border-color 0.15s; 716 + } 717 + .jon-shell-sidebar-toggle:hover { 718 + color: var(--link-color); 719 + border-color: var(--link-color); 720 + } 721 + 722 + /* Hidden sidebar state */ 723 + body.sidebar-hidden .jon-shell-sidebar { display: none; } 724 + 725 + /* ================================================================ 726 + Scrollycode theming 727 + Maps jon-shell variables to --sc-* custom properties. 728 + The extension's structural CSS uses these properties for styling. 729 + ================================================================ */ 730 + 731 + /* Theme custom properties — map shell vars to scrollycode contract */ 732 + .sc-container { 733 + --sc-font-display: var(--font-body); 734 + --sc-font-body: var(--font-body); 735 + --sc-font-code: var(--font-mono); 736 + --sc-bg: var(--bg-color); 737 + --sc-text: var(--text-color); 738 + --sc-text-dim: var(--text-muted); 739 + --sc-accent: var(--link-color); 740 + --sc-accent-soft: var(--highlight-bg); 741 + --sc-code-bg: #1a1a2e; 742 + --sc-code-text: #d4d0c8; 743 + --sc-code-gutter: #3a3a52; 744 + --sc-border: var(--border-color); 745 + --sc-focus-bg: var(--highlight-bg); 746 + --sc-panel-radius: 12px; 747 + --sc-mobile-step-bg: rgba(255,255,255,0.5); 748 + 749 + /* Syntax highlighting */ 750 + --sc-hl-keyword: #f0a6a0; 751 + --sc-hl-type: #8ec8e8; 752 + --sc-hl-string: #b8d89a; 753 + --sc-hl-comment: #6a6a82; 754 + --sc-hl-number: #ddb97a; 755 + --sc-hl-module: #e8c87a; 756 + --sc-hl-operator: #c8a8d8; 757 + --sc-hl-punct: #7a7a92; 758 + } 759 + 760 + /* Hero: centered for jon-shell */ 761 + .sc-container .sc-hero { 762 + border-bottom: 1px solid var(--sc-border); 763 + text-align: center; 764 + } 765 + .sc-container .sc-hero p { 766 + margin: 0 auto; 767 + } 768 + 769 + /* Dark mode overrides */ 770 + @media (prefers-color-scheme: dark) { 771 + .sc-container { 772 + --sc-code-bg: #0f0f18; 773 + --sc-code-text: #c8c5d8; 774 + --sc-code-gutter: #2a2a3e; 775 + --sc-mobile-step-bg: rgba(255,255,255,0.04); 776 + 777 + /* Dark syntax colors */ 778 + --sc-hl-keyword: #ff7eb3; 779 + --sc-hl-type: #7dd3fc; 780 + --sc-hl-string: #4ade80; 781 + --sc-hl-comment: #4a4a62; 782 + --sc-hl-number: #fbbf24; 783 + --sc-hl-module: #c4b5fd; 784 + --sc-hl-operator: #67e8f9; 785 + --sc-hl-punct: #4a4a62; 786 + } 787 + } 788 + |}
+392
odoc-jons-plugins/src/odoc_jons_plugins_js.ml
··· 1 + let js = 2 + {| 3 + // Global state 4 + var BASE_URL = window.BASE_URL || './'; 5 + var CURRENT_URL = window.CURRENT_URL || 'index.html'; 6 + 7 + // Compute the root URL for absolute fetching (handles SPA navigation) 8 + var ROOT_URL = new URL(BASE_URL, window.location.href).href; 9 + 10 + // DOMParser for SPA navigation 11 + var parser = new DOMParser(); 12 + 13 + // Package groups for sidebar organisation 14 + var PACKAGE_GROUPS = [ 15 + { name: 'odoc Core', 16 + packages: ['odoc', 'odoc-parser', 'odoc-driver', 'odoc-bench', 'sherlodoc'] }, 17 + { name: 'odoc Extensions', 18 + match: function(pkg) { 19 + return /^odoc-/.test(pkg) && ['odoc-parser', 'odoc-driver', 'odoc-bench'].indexOf(pkg) < 0; 20 + } }, 21 + { name: 'js_top_worker', 22 + match: function(pkg) { return /^js_top_worker/.test(pkg); } }, 23 + { name: 'Tessera', 24 + match: function(pkg) { return /^tessera-/.test(pkg); } } 25 + ]; 26 + 27 + // Extract package name from a sidebar entry's URL (e.g. "reference/odoc/index.html" -> "odoc") 28 + function pkgName(entry) { 29 + var url = entry.node && entry.node.url; 30 + if (!url) return ''; 31 + var parts = url.split('/'); 32 + return parts.length >= 2 ? parts[parts.length - 2] : ''; 33 + } 34 + 35 + // Group package entries under collapsible group headers. 36 + // Operates on the children of the "reference" node (depth 2 in the tree). 37 + function groupPackages(entries) { 38 + var groups = {}; 39 + var ungrouped = []; 40 + 41 + entries.forEach(function(entry) { 42 + var pkg = pkgName(entry); 43 + var matched = false; 44 + for (var i = 0; i < PACKAGE_GROUPS.length; i++) { 45 + var g = PACKAGE_GROUPS[i]; 46 + var inGroup = g.packages 47 + ? g.packages.indexOf(pkg) >= 0 48 + : g.match && g.match(pkg); 49 + if (inGroup) { 50 + if (!groups[g.name]) groups[g.name] = []; 51 + groups[g.name].push(entry); 52 + matched = true; 53 + break; 54 + } 55 + } 56 + if (!matched) ungrouped.push(entry); 57 + }); 58 + 59 + var result = []; 60 + PACKAGE_GROUPS.forEach(function(g) { 61 + var members = groups[g.name]; 62 + if (members && members.length > 0) { 63 + result.push({ 64 + node: { content: g.name, kind: 'group', url: null }, 65 + children: members 66 + }); 67 + } 68 + }); 69 + return result.concat(ungrouped); 70 + } 71 + 72 + // Sidebar rendering 73 + function renderEntry(entry) { 74 + var node = entry.node; 75 + var children = entry.children || []; 76 + var hasChildren = children.length > 0; 77 + 78 + // Group entries render as a heading + always-visible children (not collapsible) 79 + if (node.kind === 'group') { 80 + var html = '<li class="sidebar-group">'; 81 + html += '<span class="sidebar-group-heading">' + node.content + '</span>'; 82 + if (hasChildren) { 83 + html += '<ul>' + children.map(renderEntry).join('') + '</ul>'; 84 + } 85 + return html + '</li>'; 86 + } 87 + 88 + var isActive = node.url === CURRENT_URL; 89 + var li = '<li'; 90 + if (hasChildren) li += ' class="collapsed"'; 91 + li += '>'; 92 + if (hasChildren) { 93 + li += '<span class="sidebar-toggle"></span>'; 94 + } 95 + if (node.url) { 96 + li += '<a href="' + BASE_URL + node.url + '"' + 97 + ' data-nav="' + node.url + '"' + 98 + (isActive ? ' class="current_unit"' : '') + '>' + 99 + node.content + '</a>'; 100 + } else { 101 + li += '<span class="sidebar-label">' + node.content + '</span>'; 102 + } 103 + if (hasChildren) { 104 + li += '<ul>' + children.map(renderEntry).join('') + '</ul>'; 105 + } 106 + return li + '</li>'; 107 + } 108 + 109 + function initSidebar(data) { 110 + var container = document.getElementById('sidebar-content'); 111 + if (!container) return; 112 + // Flatten single-child wrapper nodes (e.g. "OCaml package documentation" > 113 + // "reference") — they add nesting without value. 114 + var entries = data; 115 + while (entries.length === 1 && entries[0].children && entries[0].children.length > 0) { 116 + entries = entries[0].children; 117 + } 118 + // Group the packages into sections 119 + entries = groupPackages(entries); 120 + var html = '<ul>' + entries.map(renderEntry).join('') + '</ul>'; 121 + container.innerHTML = html; 122 + 123 + // Toggle click handler — only the chevron toggle controls expand/collapse 124 + container.addEventListener('click', function(e) { 125 + var toggle = e.target.closest('.sidebar-toggle'); 126 + if (!toggle) return; 127 + e.preventDefault(); 128 + e.stopPropagation(); 129 + var li = toggle.parentElement; 130 + li.classList.toggle('collapsed'); 131 + }); 132 + 133 + updateSidebarActive(); 134 + } 135 + 136 + function updateSidebarActive() { 137 + var container = document.getElementById('sidebar-content'); 138 + if (!container) return; 139 + 140 + // Remove old active 141 + container.querySelectorAll('.current_unit').forEach(function(el) { 142 + el.classList.remove('current_unit'); 143 + }); 144 + 145 + // Find exact match 146 + var activeLink = container.querySelector('[data-nav="' + CURRENT_URL + '"]'); 147 + 148 + // If no exact match, try ancestor URLs 149 + if (!activeLink) { 150 + var parts = CURRENT_URL.split('#')[0].split('/'); 151 + while (parts.length > 1 && !activeLink) { 152 + parts.pop(); 153 + var tryUrl = parts.join('/') + '/index.html'; 154 + activeLink = container.querySelector('[data-nav="' + tryUrl + '"]'); 155 + } 156 + } 157 + 158 + if (activeLink) { 159 + activeLink.classList.add('current_unit'); 160 + // Expand all ancestor <li> nodes so the active link is visible 161 + var parent = activeLink.parentElement; 162 + while (parent && parent !== container) { 163 + if (parent.tagName === 'LI') { 164 + parent.classList.remove('collapsed'); 165 + } 166 + parent = parent.parentElement; 167 + } 168 + // Scroll into view 169 + setTimeout(function() { 170 + activeLink.scrollIntoView({ block: 'center', behavior: 'instant' }); 171 + }, 0); 172 + } 173 + } 174 + 175 + // SPA Navigation 176 + async function navigateTo(url, pushState) { 177 + if (pushState === undefined) pushState = true; 178 + try { 179 + var response = await fetch(ROOT_URL + url); 180 + if (!response.ok) throw new Error('Failed to load page'); 181 + var html = await response.text(); 182 + var doc = parser.parseFromString(html, 'text/html'); 183 + 184 + // Swap content — use DOM node adoption instead of innerHTML so that 185 + // custom elements (e.g. <x-ocaml>) get properly connected and their 186 + // connectedCallback fires. 187 + var newContent = doc.querySelector('.odoc-content'); 188 + var oldContent = document.querySelector('.odoc-content'); 189 + if (newContent && oldContent) { 190 + oldContent.textContent = ''; 191 + while (newContent.firstChild) { 192 + oldContent.appendChild(document.adoptNode(newContent.firstChild)); 193 + } 194 + } 195 + 196 + // Update title 197 + var newTitle = doc.querySelector('title'); 198 + if (newTitle) { 199 + document.title = newTitle.textContent; 200 + } 201 + 202 + // Update body class (regular page vs source page) 203 + document.body.className = doc.body.className; 204 + 205 + // Load new CSS/JS resources from fetched page 206 + var fetchedPageBase = ROOT_URL + url; 207 + var inlineScripts = Array.from(doc.querySelectorAll('head script:not([src])')); 208 + 209 + var newScriptLoadPromises = []; 210 + doc.querySelectorAll('head link[rel="stylesheet"], head script[src]').forEach(function(el) { 211 + var attr = el.tagName === 'LINK' ? 'href' : 'src'; 212 + var resUrl = el.getAttribute(attr); 213 + if (!resUrl) return; 214 + var abs = new URL(resUrl, fetchedPageBase).href; 215 + var selector = el.tagName === 'LINK' 216 + ? 'link[rel="stylesheet"]' 217 + : 'script[src]'; 218 + var already = Array.from(document.querySelectorAll('head ' + selector)).some(function(existing) { 219 + var existingUrl = existing.getAttribute(attr); 220 + if (!existingUrl) return false; 221 + return new URL(existingUrl, window.location.href).href === abs; 222 + }); 223 + if (!already) { 224 + var node; 225 + if (el.tagName === 'SCRIPT') { 226 + // Must create a fresh script element — cloneNode creates a 227 + // "parser-inserted" script that browsers refuse to execute. 228 + node = document.createElement('script'); 229 + node.src = abs; 230 + var p = new Promise(function(resolve) { 231 + node.onload = resolve; 232 + node.onerror = resolve; 233 + }); 234 + newScriptLoadPromises.push(p); 235 + } else { 236 + node = el.cloneNode(true); 237 + node.setAttribute(attr, abs); 238 + } 239 + document.head.appendChild(node); 240 + } 241 + }); 242 + 243 + // After external scripts load, execute inline scripts (deduplicated) 244 + Promise.all(newScriptLoadPromises).then(function() { 245 + inlineScripts.forEach(function(el) { 246 + var id = el.getAttribute('data-spa-inline'); 247 + if (id && document.querySelector('head script[data-spa-inline="' + id + '"]')) return; 248 + var s = el.cloneNode(true); 249 + document.head.appendChild(s); 250 + }); 251 + }); 252 + 253 + // Update state 254 + CURRENT_URL = url; 255 + if (pushState) { 256 + history.pushState({ url: url }, '', ROOT_URL + url); 257 + } 258 + 259 + updateSidebarActive(); 260 + 261 + // Scroll handling 262 + var hash = url.indexOf('#') >= 0 ? '#' + url.split('#')[1] : ''; 263 + if (hash) { 264 + setTimeout(function() { 265 + var target = document.querySelector(hash); 266 + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 267 + }, 100); 268 + } else { 269 + window.scrollTo(0, 0); 270 + } 271 + 272 + } catch (e) { 273 + console.error('Navigation failed:', e); 274 + window.location.href = ROOT_URL + url; 275 + } 276 + } 277 + 278 + // Popstate for back/forward 279 + window.addEventListener('popstate', function(e) { 280 + if (e.state && e.state.url) { 281 + navigateTo(e.state.url, false); 282 + } 283 + }); 284 + 285 + // Click interception for SPA navigation 286 + document.addEventListener('click', function(e) { 287 + var link = e.target.closest('a[href]'); 288 + if (!link) return; 289 + 290 + var href = link.getAttribute('href'); 291 + if (!href) return; 292 + 293 + // Skip external, hash-only, mailto, javascript links 294 + if (href.indexOf('http') === 0 || href.indexOf('//') === 0 || href.indexOf('#') === 0 || 295 + href.indexOf('mailto:') === 0 || href.indexOf('javascript:') === 0) { 296 + return; 297 + } 298 + 299 + // If the link has data-nav, use it directly (sidebar links) 300 + var navPath = link.dataset.nav; 301 + if (navPath) { 302 + // Only SPA-navigate within reference/ 303 + if (navPath.indexOf('reference/') !== 0) return; 304 + e.preventDefault(); 305 + navigateTo(navPath); 306 + return; 307 + } 308 + 309 + // Resolve relative href against current URL 310 + var targetUrl = href; 311 + if (href.indexOf('/') === 0) { 312 + targetUrl = href.slice(1); 313 + } else if (href.indexOf('./') === 0) { 314 + var currentDir = CURRENT_URL.substring(0, CURRENT_URL.lastIndexOf('/') + 1); 315 + targetUrl = currentDir + href.slice(2); 316 + } else if (href.indexOf('../') === 0) { 317 + var currentParts = CURRENT_URL.split('/'); 318 + currentParts.pop(); 319 + var hrefParts = href.split('/'); 320 + for (var i = 0; i < hrefParts.length; i++) { 321 + var part = hrefParts[i]; 322 + if (part === '..') { 323 + currentParts.pop(); 324 + } else if (part !== '.') { 325 + currentParts.push(part); 326 + } 327 + } 328 + targetUrl = currentParts.join('/'); 329 + } else { 330 + var curDir = CURRENT_URL.substring(0, CURRENT_URL.lastIndexOf('/') + 1); 331 + targetUrl = curDir + href; 332 + } 333 + 334 + // Skip if navigating to same page with just a hash change 335 + if (targetUrl.indexOf('#') >= 0) { 336 + var pathAndHash = targetUrl.split('#'); 337 + if (pathAndHash[0] === CURRENT_URL || pathAndHash[0] === '') { 338 + return; 339 + } 340 + } 341 + 342 + // Skip links outside /reference/ (e.g. /blog/ in header) 343 + if (targetUrl.indexOf('reference/') !== 0) { 344 + return; 345 + } 346 + 347 + e.preventDefault(); 348 + navigateTo(targetUrl); 349 + }); 350 + 351 + // Sidebar toggle 352 + function initSidebarToggle() { 353 + var btn = document.querySelector('.jon-shell-sidebar-toggle'); 354 + if (!btn) return; 355 + var sidebar = document.querySelector('.jon-shell-sidebar'); 356 + if (!sidebar) { btn.style.display = 'none'; return; } 357 + 358 + // Restore preference 359 + if (localStorage.getItem('jon-shell-sidebar') === 'hidden') { 360 + document.body.classList.add('sidebar-hidden'); 361 + } 362 + 363 + btn.addEventListener('click', function() { 364 + document.body.classList.toggle('sidebar-hidden'); 365 + var hidden = document.body.classList.contains('sidebar-hidden'); 366 + localStorage.setItem('jon-shell-sidebar', hidden ? 'hidden' : 'visible'); 367 + }); 368 + } 369 + 370 + // Initialize 371 + (function() { 372 + // Mark header for SPA detection 373 + var header = document.querySelector('.jon-shell-header'); 374 + if (header) header.dataset.spaInit = 'true'; 375 + 376 + // Set initial history state 377 + history.replaceState({ url: CURRENT_URL }, '', window.location.href); 378 + 379 + // Load sidebar data only for reference pages 380 + if (CURRENT_URL.indexOf('reference/') === 0) { 381 + fetch(ROOT_URL + 'sidebar.json') 382 + .then(function(r) { 383 + if (!r.ok) throw new Error('sidebar.json: ' + r.status); 384 + return r.json(); 385 + }) 386 + .then(function(data) { initSidebar(data); }) 387 + .catch(function(e) { console.warn('Failed to load sidebar:', e); }); 388 + } 389 + 390 + initSidebarToggle(); 391 + })(); 392 + |}