My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

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

Add docsite shell plugin and expose structured sidebar data

Extend the odoc shell plugin interface with a sidebar_data field that
provides the raw Odoc_document.Sidebar.t alongside the pre-rendered HTML
sidebar. This allows shell plugins to generate custom sidebar UIs from
structured data rather than being limited to the default HTML rendering.

Create the odoc-docsite shell plugin (--shell docsite) that generates a
docs.rs-inspired documentation website with a fixed header, interactive
sidebar with package selector, full-text search, SPA navigation, dark
mode, and responsive layout. The sidebar JSON is embedded inline in each
page for client-side rendering.

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

+1894 -4
+15
odoc-docsite/dune-project
··· 1 + (lang dune 3.18) 2 + (using dune_site 0.1) 3 + (name odoc-docsite) 4 + (generate_opam_files true) 5 + 6 + (package 7 + (name odoc-docsite) 8 + (synopsis "A docs.rs-inspired documentation shell plugin for odoc") 9 + (description 10 + "Provides a 'docsite' shell for odoc that generates themed documentation 11 + websites with a persistent sidebar, search, package selector, and 12 + dark mode support.") 13 + (depends 14 + (ocaml (>= 4.14)) 15 + odoc))
+29
odoc-docsite/odoc-docsite.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "A docs.rs-inspired documentation shell plugin for odoc" 4 + description: """ 5 + Provides a 'docsite' shell for odoc that generates themed documentation 6 + websites with a persistent sidebar, search, package selector, and 7 + dark mode support.""" 8 + depends: [ 9 + "dune" {>= "3.18"} 10 + "ocaml" {>= "4.14"} 11 + "odoc" 12 + ] 13 + build: [ 14 + ["dune" "subst"] {dev} 15 + [ 16 + "dune" 17 + "build" 18 + "-p" 19 + name 20 + "-j" 21 + jobs 22 + "--promote-install-files=false" 23 + "@install" 24 + "@runtest" {with-test} 25 + "@doc" {with-doc} 26 + ] 27 + ["dune" "install" "-p" name "--create-install-files" name] 28 + ] 29 + x-maintenance-intent: ["(latest)"]
+9
odoc-docsite/src/dune
··· 1 + (library 2 + (public_name odoc-docsite.impl) 3 + (name odoc_docsite) 4 + (libraries odoc.html odoc.extension_api)) 5 + 6 + (plugin 7 + (name odoc-docsite) 8 + (libraries odoc-docsite.impl) 9 + (site (odoc extensions)))
+802
odoc-docsite/src/odoc_docsite_css.ml
··· 1 + let css = {| 2 + :root { 3 + --bg-color: #f8f9fa; 4 + --sidebar-bg: #fff; 5 + --content-bg: #fff; 6 + --text-color: #24292f; 7 + --text-muted: #656d76; 8 + --link-color: #0969da; 9 + --link-hover: #0550ae; 10 + --border-color: #d1d9e0; 11 + --code-bg: #f6f8fa; 12 + --code-border: #d1d9e0; 13 + --highlight-bg: rgba(9, 105, 218, 0.08); 14 + --highlight-border: #0969da; 15 + --include-bg: rgba(0, 0, 0, 0.02); 16 + --sidebar-width: 280px; 17 + --header-height: 56px; 18 + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); 19 + --shadow-md: 0 3px 6px rgba(0, 0, 0, 0.08); 20 + } 21 + 22 + @media (prefers-color-scheme: dark) { 23 + :root { 24 + --bg-color: #0d1117; 25 + --sidebar-bg: #161b22; 26 + --content-bg: #161b22; 27 + --text-color: #e6edf3; 28 + --text-muted: #8b949e; 29 + --link-color: #58a6ff; 30 + --link-hover: #79c0ff; 31 + --border-color: #30363d; 32 + --code-bg: #21262d; 33 + --code-border: #30363d; 34 + --highlight-bg: rgba(88, 166, 255, 0.12); 35 + --highlight-border: #58a6ff; 36 + --include-bg: rgba(255, 255, 255, 0.02); 37 + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); 38 + --shadow-md: 0 3px 6px rgba(0, 0, 0, 0.3); 39 + } 40 + } 41 + 42 + * { 43 + box-sizing: border-box; 44 + margin: 0; 45 + padding: 0; 46 + } 47 + 48 + body { 49 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 50 + font-size: 16px; 51 + line-height: 1.6; 52 + color: var(--text-color); 53 + background: var(--bg-color); 54 + } 55 + 56 + /* Site Header */ 57 + .docsite-header { 58 + position: fixed; 59 + top: 0; 60 + left: 0; 61 + right: 0; 62 + height: var(--header-height); 63 + background: var(--sidebar-bg); 64 + border-bottom: 1px solid var(--border-color); 65 + display: flex; 66 + align-items: center; 67 + padding: 0 24px; 68 + z-index: 100; 69 + box-shadow: var(--shadow-sm); 70 + } 71 + 72 + .docsite-header-brand { 73 + font-weight: 600; 74 + font-size: 1.2rem; 75 + color: var(--text-color); 76 + text-decoration: none; 77 + letter-spacing: -0.01em; 78 + } 79 + 80 + .docsite-header-brand:hover { 81 + color: var(--link-color); 82 + } 83 + 84 + /* Search */ 85 + .docsite-search-container { 86 + flex: 1; 87 + max-width: 400px; 88 + margin: 0 40px; 89 + } 90 + 91 + .search-wrapper { 92 + position: relative; 93 + } 94 + 95 + .search-input { 96 + width: 100%; 97 + padding: 8px 12px 8px 36px; 98 + font-size: 14px; 99 + border: 1px solid var(--border-color); 100 + border-radius: 6px; 101 + background: var(--bg-color); 102 + color: var(--text-color); 103 + transition: border-color 0.2s, box-shadow 0.2s; 104 + } 105 + 106 + .search-input:focus { 107 + outline: none; 108 + border-color: var(--link-color); 109 + box-shadow: 0 0 0 3px rgba(59, 110, 165, 0.15); 110 + } 111 + 112 + .search-input::placeholder { 113 + color: var(--text-muted); 114 + } 115 + 116 + .search-icon { 117 + position: absolute; 118 + left: 12px; 119 + top: 50%; 120 + transform: translateY(-50%); 121 + color: var(--text-muted); 122 + pointer-events: none; 123 + } 124 + 125 + .search-shortcut { 126 + position: absolute; 127 + right: 8px; 128 + top: 50%; 129 + transform: translateY(-50%); 130 + padding: 2px 6px; 131 + font-size: 11px; 132 + font-family: monospace; 133 + background: var(--code-bg); 134 + border: 1px solid var(--border-color); 135 + border-radius: 3px; 136 + color: var(--text-muted); 137 + } 138 + 139 + .search-results { 140 + position: absolute; 141 + top: calc(100% + 4px); 142 + left: 0; 143 + right: 0; 144 + max-height: 400px; 145 + overflow-y: auto; 146 + background: var(--content-bg); 147 + border: 1px solid var(--border-color); 148 + border-radius: 8px; 149 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); 150 + z-index: 1000; 151 + display: none; 152 + } 153 + 154 + .search-results.active { 155 + display: block; 156 + } 157 + 158 + .search-result { 159 + display: block; 160 + padding: 10px 14px; 161 + color: var(--text-color); 162 + text-decoration: none; 163 + border-bottom: 1px solid var(--border-color); 164 + } 165 + 166 + .search-result:last-child { 167 + border-bottom: none; 168 + } 169 + 170 + .search-result:hover, 171 + .search-result:focus { 172 + background: var(--highlight-bg); 173 + outline: none; 174 + } 175 + 176 + .search-result .entry-name { 177 + font-weight: 500; 178 + } 179 + 180 + .search-result .entry-kind { 181 + font-size: 0.85em; 182 + color: var(--text-muted); 183 + margin-left: 8px; 184 + } 185 + 186 + .search-no-result { 187 + padding: 16px; 188 + text-align: center; 189 + color: var(--text-muted); 190 + } 191 + 192 + .search-busy::after { 193 + content: ""; 194 + display: inline-block; 195 + width: 16px; 196 + height: 16px; 197 + border: 2px solid var(--border-color); 198 + border-top-color: var(--link-color); 199 + border-radius: 50%; 200 + animation: spin 0.8s linear infinite; 201 + position: absolute; 202 + right: 40px; 203 + top: 50%; 204 + transform: translateY(-50%); 205 + } 206 + 207 + @keyframes spin { 208 + to { transform: translateY(-50%) rotate(360deg); } 209 + } 210 + 211 + /* Layout */ 212 + .docsite-layout { 213 + display: flex; 214 + margin-top: var(--header-height); 215 + min-height: calc(100vh - var(--header-height)); 216 + } 217 + 218 + /* Sidebar */ 219 + .docsite-sidebar { 220 + position: fixed; 221 + top: var(--header-height); 222 + left: 0; 223 + width: var(--sidebar-width); 224 + height: calc(100vh - var(--header-height)); 225 + background: var(--sidebar-bg); 226 + border-right: 1px solid var(--border-color); 227 + overflow-y: auto; 228 + padding: 16px 0; 229 + z-index: 50; 230 + } 231 + 232 + .sidebar-nav { 233 + padding: 0 12px; 234 + } 235 + 236 + .sidebar-section { 237 + margin-bottom: 8px; 238 + } 239 + 240 + .sidebar-link { 241 + display: block; 242 + padding: 6px 12px; 243 + color: var(--text-color); 244 + text-decoration: none; 245 + border-radius: 4px; 246 + font-size: 14px; 247 + } 248 + 249 + .sidebar-link:hover:not(.active) { 250 + background: var(--bg-color); 251 + } 252 + 253 + .sidebar-link.active { 254 + background: var(--link-color); 255 + color: #fff; 256 + font-weight: 500; 257 + } 258 + 259 + .sidebar-group { 260 + margin-left: 0; 261 + } 262 + 263 + .sidebar-group-header { 264 + display: flex; 265 + align-items: center; 266 + padding: 6px 12px; 267 + cursor: pointer; 268 + font-size: 14px; 269 + font-weight: 500; 270 + color: var(--text-color); 271 + border-radius: 4px; 272 + user-select: none; 273 + } 274 + 275 + .sidebar-group-header:hover { 276 + background: var(--bg-color); 277 + } 278 + 279 + .sidebar-toggle { 280 + width: 16px; 281 + height: 16px; 282 + margin-right: 6px; 283 + transition: transform 0.2s; 284 + } 285 + 286 + .sidebar-toggle-spacer { 287 + display: inline-block; 288 + width: 16px; 289 + height: 16px; 290 + margin-left: 12px; 291 + margin-right: 6px; 292 + flex-shrink: 0; 293 + } 294 + 295 + .sidebar-leaf { 296 + display: flex; 297 + align-items: center; 298 + } 299 + 300 + /* Package selector */ 301 + .package-selector { 302 + padding: 12px; 303 + border-bottom: 1px solid var(--border-color); 304 + margin-bottom: 8px; 305 + } 306 + 307 + .package-selector select { 308 + width: 100%; 309 + padding: 8px 12px; 310 + font-size: 14px; 311 + font-weight: 500; 312 + border: 1px solid var(--border-color); 313 + border-radius: 6px; 314 + background: var(--content-bg); 315 + color: var(--text-color); 316 + cursor: pointer; 317 + appearance: none; 318 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 319 + background-repeat: no-repeat; 320 + background-position: right 12px center; 321 + padding-right: 36px; 322 + } 323 + 324 + .package-selector select:hover { 325 + border-color: var(--link-color); 326 + } 327 + 328 + .package-selector select:focus { 329 + outline: none; 330 + border-color: var(--link-color); 331 + box-shadow: 0 0 0 3px rgba(59, 110, 165, 0.15); 332 + } 333 + 334 + .sidebar-group.collapsed .sidebar-toggle { 335 + transform: rotate(-90deg); 336 + } 337 + 338 + .sidebar-group.collapsed .sidebar-children { 339 + display: none; 340 + } 341 + 342 + .sidebar-children { 343 + margin-left: 12px; 344 + border-left: 1px solid var(--border-color); 345 + padding-left: 8px; 346 + } 347 + 348 + /* Sidebar entry type indicators */ 349 + .sidebar-kind { 350 + display: inline-flex; 351 + align-items: center; 352 + justify-content: center; 353 + width: 18px; 354 + height: 18px; 355 + margin-right: 6px; 356 + border-radius: 3px; 357 + font-size: 10px; 358 + font-weight: 600; 359 + flex-shrink: 0; 360 + } 361 + 362 + .sidebar-kind-package { 363 + background: #8250df; 364 + color: white; 365 + } 366 + 367 + .sidebar-kind-module { 368 + background: #bf8700; 369 + color: white; 370 + } 371 + 372 + .sidebar-kind-page { 373 + background: #1a7f37; 374 + color: white; 375 + } 376 + 377 + .sidebar-group-header .sidebar-kind { 378 + margin-left: 0; 379 + } 380 + 381 + .sidebar-link .sidebar-kind { 382 + margin-left: 0; 383 + } 384 + 385 + .sidebar-link.active .sidebar-kind { 386 + background: rgba(255, 255, 255, 0.3); 387 + } 388 + 389 + /* Main Content */ 390 + .docsite-main { 391 + flex: 1; 392 + margin-left: var(--sidebar-width); 393 + padding: 24px 40px 60px; 394 + max-width: calc(100% - var(--sidebar-width)); 395 + background: var(--content-bg); 396 + min-height: calc(100vh - var(--header-height)); 397 + } 398 + 399 + /* Breadcrumbs */ 400 + .odoc-nav { 401 + display: flex; 402 + align-items: center; 403 + flex-wrap: wrap; 404 + gap: 4px; 405 + padding: 12px 0; 406 + margin-bottom: 16px; 407 + font-size: 14px; 408 + border-bottom: 1px solid var(--border-color); 409 + } 410 + 411 + .breadcrumb-item { 412 + color: var(--link-color); 413 + text-decoration: none; 414 + } 415 + 416 + .breadcrumb-item:hover { 417 + text-decoration: underline; 418 + } 419 + 420 + .breadcrumb-separator { 421 + color: var(--text-muted); 422 + margin: 0 4px; 423 + } 424 + 425 + .breadcrumb-current { 426 + color: var(--text-color); 427 + font-weight: 500; 428 + } 429 + 430 + /* Content Styles */ 431 + .odoc-content { 432 + max-width: 900px; 433 + } 434 + 435 + .odoc-content h1 { 436 + font-size: 2rem; 437 + font-weight: 600; 438 + margin-bottom: 16px; 439 + padding-bottom: 8px; 440 + border-bottom: 2px solid var(--border-color); 441 + } 442 + 443 + .odoc-content h2 { 444 + font-size: 1.5rem; 445 + font-weight: 600; 446 + margin-top: 32px; 447 + margin-bottom: 12px; 448 + padding-bottom: 6px; 449 + border-bottom: 1px solid var(--border-color); 450 + } 451 + 452 + .odoc-content h3 { 453 + font-size: 1.25rem; 454 + font-weight: 600; 455 + margin-top: 24px; 456 + margin-bottom: 8px; 457 + } 458 + 459 + .odoc-content h4, .odoc-content h5, .odoc-content h6 { 460 + font-size: 1.1rem; 461 + font-weight: 600; 462 + margin-top: 20px; 463 + margin-bottom: 8px; 464 + } 465 + 466 + .odoc-content p { 467 + margin-bottom: 16px; 468 + } 469 + 470 + .odoc-content a { 471 + color: var(--link-color); 472 + text-decoration: none; 473 + } 474 + 475 + .odoc-content a:hover { 476 + text-decoration: underline; 477 + } 478 + 479 + .odoc-content code { 480 + font-family: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; 481 + font-size: 0.9em; 482 + background: var(--code-bg); 483 + padding: 2px 6px; 484 + border-radius: 4px; 485 + border: 1px solid var(--code-border); 486 + } 487 + 488 + .odoc-content pre { 489 + background: var(--code-bg); 490 + border: 1px solid var(--code-border); 491 + border-radius: 8px; 492 + padding: 16px; 493 + overflow-x: auto; 494 + margin-bottom: 16px; 495 + box-shadow: var(--shadow-sm); 496 + } 497 + 498 + .odoc-content pre code { 499 + background: none; 500 + border: none; 501 + padding: 0; 502 + font-size: 0.875rem; 503 + line-height: 1.5; 504 + } 505 + 506 + .odoc-content ul, .odoc-content ol { 507 + margin-bottom: 16px; 508 + padding-left: 24px; 509 + } 510 + 511 + .odoc-content li { 512 + margin-bottom: 4px; 513 + } 514 + 515 + .odoc-content blockquote { 516 + border-left: 4px solid var(--link-color); 517 + margin: 16px 0; 518 + padding: 8px 16px; 519 + background: var(--code-bg); 520 + color: var(--text-muted); 521 + } 522 + 523 + .odoc-content table { 524 + width: 100%; 525 + border-collapse: collapse; 526 + margin-bottom: 16px; 527 + } 528 + 529 + .odoc-content th, .odoc-content td { 530 + padding: 10px 12px; 531 + border: 1px solid var(--border-color); 532 + text-align: left; 533 + } 534 + 535 + .odoc-content th { 536 + background: var(--code-bg); 537 + font-weight: 600; 538 + } 539 + 540 + /* odoc-specific styles */ 541 + .odoc-spec { 542 + margin: 16px 0; 543 + padding: 12px 16px; 544 + background: var(--code-bg); 545 + border: 1px solid var(--code-border); 546 + border-radius: 8px; 547 + border-left: 3px solid var(--link-color); 548 + box-shadow: var(--shadow-sm); 549 + } 550 + 551 + .odoc-spec code { 552 + background: none; 553 + border: none; 554 + padding: 0; 555 + font-size: inherit; 556 + } 557 + 558 + .spec { 559 + font-family: "SF Mono", Consolas, monospace; 560 + font-size: 0.9rem; 561 + } 562 + 563 + .spec-doc { 564 + margin-top: 8px; 565 + padding-top: 8px; 566 + border-top: 1px solid var(--border-color); 567 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; 568 + font-size: 0.95rem; 569 + } 570 + 571 + /* Hide OCaml comment delimiters in documentation */ 572 + .comment-delim { 573 + display: none; 574 + } 575 + 576 + .odoc-spec ol { 577 + list-style: none; 578 + margin: 0; 579 + padding: 0; 580 + } 581 + 582 + .odoc-spec li { 583 + margin: 4px 0; 584 + } 585 + 586 + .def-doc { 587 + display: inline; 588 + margin-left: 8px; 589 + color: var(--text-muted); 590 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; 591 + font-size: 0.9rem; 592 + } 593 + 594 + .def-doc p { 595 + display: inline; 596 + margin: 0; 597 + } 598 + 599 + /* Include blocks */ 600 + .odoc-include { 601 + margin: 16px 0; 602 + margin-left: 12px; 603 + padding: 12px; 604 + border: 1px solid var(--border-color); 605 + border-left: 3px solid var(--text-muted); 606 + border-radius: 8px; 607 + background: var(--include-bg); 608 + } 609 + 610 + .odoc-include > details { 611 + margin: 0; 612 + } 613 + 614 + .odoc-include > details > summary { 615 + padding: 8px 0; 616 + cursor: pointer; 617 + font-family: "SF Mono", Consolas, monospace; 618 + font-size: 0.9rem; 619 + list-style: none; 620 + } 621 + 622 + .odoc-include > details > summary::-webkit-details-marker { 623 + display: none; 624 + } 625 + 626 + .odoc-include > details > summary::before { 627 + content: "\25B6"; 628 + display: inline-block; 629 + margin-right: 8px; 630 + transition: transform 0.2s; 631 + font-size: 0.7em; 632 + color: var(--text-muted); 633 + } 634 + 635 + .odoc-include > details[open] > summary::before { 636 + transform: rotate(90deg); 637 + } 638 + 639 + .anchor { 640 + color: var(--text-muted); 641 + text-decoration: none; 642 + margin-left: 4px; 643 + opacity: 0; 644 + transition: opacity 0.15s; 645 + } 646 + 647 + h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, 648 + h4:hover .anchor, h5:hover .anchor, h6:hover .anchor, 649 + .spec:hover .anchor { 650 + opacity: 1; 651 + } 652 + 653 + .anchor:hover { 654 + color: var(--link-color); 655 + } 656 + 657 + /* Highlight target element from URL hash */ 658 + :target, 659 + .hash-highlight { 660 + position: relative; 661 + background: var(--highlight-bg); 662 + border-radius: 4px; 663 + box-shadow: inset 3px 0 0 var(--highlight-border); 664 + padding-left: 12px; 665 + margin-left: -12px; 666 + } 667 + 668 + .hash-highlight { 669 + animation: highlight-pulse 1.5s ease-out; 670 + } 671 + 672 + @keyframes highlight-pulse { 673 + 0% { 674 + background: rgba(9, 105, 218, 0.2); 675 + box-shadow: inset 3px 0 0 var(--highlight-border), 0 0 12px rgba(9, 105, 218, 0.3); 676 + } 677 + 100% { 678 + background: var(--highlight-bg); 679 + box-shadow: inset 3px 0 0 var(--highlight-border); 680 + } 681 + } 682 + 683 + @media (prefers-color-scheme: dark) { 684 + @keyframes highlight-pulse { 685 + 0% { 686 + background: rgba(88, 166, 255, 0.25); 687 + box-shadow: inset 3px 0 0 var(--highlight-border), 0 0 12px rgba(88, 166, 255, 0.3); 688 + } 689 + 100% { 690 + background: var(--highlight-bg); 691 + box-shadow: inset 3px 0 0 var(--highlight-border); 692 + } 693 + } 694 + } 695 + 696 + @keyframes highlight-fade { 697 + 0% { background-color: var(--highlight-bg); } 698 + 100% { background-color: transparent; } 699 + } 700 + 701 + /* Table of Contents (in-page) */ 702 + .odoc-tocs { 703 + position: fixed; 704 + right: 24px; 705 + top: calc(var(--header-height) + 24px); 706 + width: 200px; 707 + max-height: calc(100vh - var(--header-height) - 48px); 708 + overflow-y: auto; 709 + font-size: 13px; 710 + padding: 16px; 711 + background: var(--sidebar-bg); 712 + border: 1px solid var(--border-color); 713 + border-radius: 8px; 714 + box-shadow: var(--shadow-sm); 715 + } 716 + 717 + .odoc-tocs .odoc-toc-title { 718 + font-weight: 600; 719 + margin-bottom: 12px; 720 + color: var(--text-color); 721 + font-size: 12px; 722 + letter-spacing: 0.02em; 723 + } 724 + 725 + .odoc-tocs ul { 726 + list-style: none; 727 + margin: 0; 728 + padding: 0; 729 + border-left: 1px solid var(--border-color); 730 + } 731 + 732 + .odoc-tocs li { 733 + margin: 0; 734 + } 735 + 736 + .odoc-tocs a { 737 + color: var(--text-muted); 738 + text-decoration: none; 739 + display: block; 740 + padding: 4px 0 4px 12px; 741 + margin-left: -1px; 742 + border-left: 2px solid transparent; 743 + transition: color 0.15s, border-color 0.15s; 744 + } 745 + 746 + .odoc-tocs a:hover { 747 + color: var(--link-color); 748 + border-left-color: var(--link-color); 749 + } 750 + 751 + .odoc-tocs ul ul { 752 + margin-left: 12px; 753 + border-left: none; 754 + } 755 + 756 + .odoc-tocs ul ul a { 757 + padding-left: 12px; 758 + font-size: 12px; 759 + } 760 + 761 + @media (max-width: 1400px) { 762 + .odoc-tocs { 763 + display: none; 764 + } 765 + } 766 + 767 + /* Responsive */ 768 + @media (max-width: 768px) { 769 + :root { 770 + --sidebar-width: 0; 771 + } 772 + 773 + .docsite-sidebar { 774 + transform: translateX(-100%); 775 + transition: transform 0.3s; 776 + } 777 + 778 + .docsite-sidebar.open { 779 + transform: translateX(0); 780 + width: 280px; 781 + } 782 + 783 + .docsite-main { 784 + margin-left: 0; 785 + max-width: 100%; 786 + padding: 16px 20px; 787 + } 788 + 789 + .menu-toggle { 790 + display: block; 791 + } 792 + } 793 + 794 + .menu-toggle { 795 + display: none; 796 + background: none; 797 + border: none; 798 + padding: 8px; 799 + cursor: pointer; 800 + color: var(--text-color); 801 + } 802 + |}
+526
odoc-docsite/src/odoc_docsite_js.ml
··· 1 + let js = {| 2 + // Global state 3 + const BASE_URL = window.BASE_URL || './'; 4 + let CURRENT_URL = window.CURRENT_URL || 'index.html'; 5 + let sidebarData = null; 6 + 7 + // Compute the root URL for absolute fetching (handles SPA navigation) 8 + const ROOT_URL = new URL(BASE_URL, window.location.href).href; 9 + 10 + // Sidebar state management 11 + const STORAGE_KEY = 'odoc-docsite-sidebar-state'; 12 + let sidebarState = {}; 13 + try { 14 + sidebarState = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); 15 + } catch (e) {} 16 + 17 + function saveSidebarState() { 18 + try { 19 + localStorage.setItem(STORAGE_KEY, JSON.stringify(sidebarState)); 20 + } catch (e) {} 21 + } 22 + 23 + function escapeHtml(s) { 24 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') 25 + .replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 26 + } 27 + 28 + // Sidebar rendering 29 + function kindBadge(kind) { 30 + if (!kind) return ''; 31 + const labels = { package: 'P', module: 'M', page: 'pg' }; 32 + const label = labels[kind] || kind[0].toUpperCase(); 33 + return '<span class="sidebar-kind sidebar-kind-' + kind + '" title="' + kind + '">' + label + '</span>'; 34 + } 35 + 36 + function renderSidebarEntry(entry) { 37 + var node = entry.node; 38 + var hasChildren = entry.children && entry.children.length > 0; 39 + var isActive = node.url === CURRENT_URL; 40 + var stateKey = node.content; 41 + // Default to collapsed unless explicitly expanded 42 + var isCollapsed = sidebarState[stateKey] !== true; 43 + var badge = kindBadge(node.kind); 44 + 45 + // Render packages/modules with children as collapsible groups 46 + if (hasChildren) { 47 + var childrenHtml = entry.children.map(function(c) { return renderSidebarEntry(c); }).join(''); 48 + var linkHtml = node.url 49 + ? '<a class="sidebar-link' + (isActive ? ' active' : '') + '" href="' + BASE_URL + node.url + '" data-nav="' + node.url + '">' + badge + escapeHtml(node.content) + '</a>' 50 + : '<span>' + badge + escapeHtml(node.content) + '</span>'; 51 + return '<div class="sidebar-group' + (isCollapsed ? ' collapsed' : '') + '" data-id="' + escapeHtml(stateKey) + '">' + 52 + '<div class="sidebar-group-header">' + 53 + '<svg class="sidebar-toggle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' + 54 + '<polyline points="6 9 12 15 18 9"></polyline>' + 55 + '</svg>' + 56 + linkHtml + 57 + '</div>' + 58 + '<div class="sidebar-children">' + childrenHtml + '</div>' + 59 + '</div>'; 60 + } else if (node.url) { 61 + // Leaf nodes: add spacer for alignment with expandable items 62 + var spacer = (node.kind === 'package' || node.kind === 'module') 63 + ? '<span class="sidebar-toggle-spacer"></span>' : ''; 64 + return '<a class="sidebar-link sidebar-leaf' + (isActive ? ' active' : '') + '" href="' + BASE_URL + node.url + '" data-nav="' + node.url + '">' + spacer + badge + escapeHtml(node.content) + '</a>'; 65 + } else { 66 + return '<span class="sidebar-link">' + badge + escapeHtml(node.content) + '</span>'; 67 + } 68 + } 69 + 70 + function updateSidebarActive(options) { 71 + options = options || {}; 72 + var scrollIntoView = options.scrollIntoView || false; 73 + var updatePackageFilter = options.updatePackageFilter || false; 74 + var container = document.getElementById('sidebar-content'); 75 + if (!container) return; 76 + 77 + // Remove old active 78 + container.querySelectorAll('.sidebar-link.active').forEach(function(el) { el.classList.remove('active'); }); 79 + 80 + // Try to find exact match first 81 + var activeLink = container.querySelector('[data-nav="' + CURRENT_URL + '"]'); 82 + 83 + // If no exact match, find the best matching ancestor 84 + if (!activeLink) { 85 + var urlWithoutHash = CURRENT_URL.split('#')[0]; 86 + var parts = urlWithoutHash.split('/'); 87 + 88 + while (parts.length > 1 && !activeLink) { 89 + parts.pop(); 90 + var tryUrl = parts.join('/') + '/index.html'; 91 + activeLink = container.querySelector('[data-nav="' + tryUrl + '"]'); 92 + } 93 + 94 + if (!activeLink) { 95 + var urlPackage = getPackageFromUrl(CURRENT_URL); 96 + if (urlPackage) { 97 + activeLink = container.querySelector('[data-nav="' + urlPackage + '/index.html"]'); 98 + } 99 + } 100 + } 101 + 102 + if (activeLink) { 103 + activeLink.classList.add('active'); 104 + var parent = activeLink.closest('.sidebar-group'); 105 + while (parent) { 106 + parent.classList.remove('collapsed'); 107 + sidebarState[parent.dataset.id] = true; 108 + parent = parent.parentElement.closest('.sidebar-group'); 109 + } 110 + saveSidebarState(); 111 + if (scrollIntoView) { 112 + setTimeout(function() { activeLink.scrollIntoView({ block: 'center', behavior: 'instant' }); }, 0); 113 + } 114 + } 115 + 116 + if (updatePackageFilter) { 117 + var select = document.getElementById('package-select'); 118 + if (select && sidebarData) { 119 + var urlPackage2 = getPackageFromUrl(CURRENT_URL); 120 + var packages = sidebarData 121 + .filter(function(entry) { return entry.node.kind === 'package'; }) 122 + .map(function(entry) { return entry.node.content; }); 123 + 124 + if (CURRENT_URL === 'index.html' || !urlPackage2) { 125 + if (select.value !== '') { 126 + select.value = ''; 127 + selectedPackage = ''; 128 + filterSidebarByPackage(''); 129 + } 130 + } else if (packages.indexOf(urlPackage2) >= 0 && select.value !== urlPackage2) { 131 + select.value = urlPackage2; 132 + selectedPackage = urlPackage2; 133 + filterSidebarByPackage(urlPackage2); 134 + } 135 + } 136 + } 137 + } 138 + 139 + function initSidebar(data) { 140 + sidebarData = data; 141 + var container = document.getElementById('sidebar-content'); 142 + if (!container) return; 143 + 144 + if (CURRENT_URL === 'index.html') { 145 + sidebarState = {}; 146 + saveSidebarState(); 147 + } 148 + 149 + var html = data.map(function(entry) { return renderSidebarEntry(entry); }).join(''); 150 + container.innerHTML = html; 151 + 152 + container.querySelectorAll('.sidebar-group-header').forEach(function(header) { 153 + header.addEventListener('click', function(e) { 154 + if (e.target.closest('a')) return; 155 + var group = header.closest('.sidebar-group'); 156 + var id = group.dataset.id; 157 + group.classList.toggle('collapsed'); 158 + sidebarState[id] = !group.classList.contains('collapsed'); 159 + saveSidebarState(); 160 + }); 161 + }); 162 + 163 + initPackageSelector(data); 164 + 165 + if (CURRENT_URL !== 'index.html') { 166 + updateSidebarActive({ scrollIntoView: true, updatePackageFilter: true }); 167 + } 168 + } 169 + 170 + // Package selector functionality 171 + var PACKAGE_STORAGE_KEY = 'odoc-docsite-selected-package'; 172 + var selectedPackage = ''; 173 + try { 174 + selectedPackage = localStorage.getItem(PACKAGE_STORAGE_KEY) || ''; 175 + } catch (e) {} 176 + 177 + function saveSelectedPackage(pkg) { 178 + selectedPackage = pkg; 179 + try { 180 + localStorage.setItem(PACKAGE_STORAGE_KEY, pkg); 181 + } catch (e) {} 182 + } 183 + 184 + function getPackageFromUrl(url) { 185 + var parts = url.split('/'); 186 + return parts.length > 0 ? parts[0] : ''; 187 + } 188 + 189 + function initPackageSelector(data) { 190 + var select = document.getElementById('package-select'); 191 + if (!select) return; 192 + 193 + var packages = data 194 + .filter(function(entry) { return entry.node.kind === 'package'; }) 195 + .map(function(entry) { return entry.node.content; }) 196 + .sort(); 197 + 198 + select.innerHTML = '<option value="">All packages</option>'; 199 + packages.forEach(function(pkg) { 200 + var option = document.createElement('option'); 201 + option.value = pkg; 202 + option.textContent = pkg; 203 + select.appendChild(option); 204 + }); 205 + 206 + var urlPackage = getPackageFromUrl(CURRENT_URL); 207 + if (CURRENT_URL === 'index.html' || !urlPackage) { 208 + selectedPackage = ''; 209 + select.value = ''; 210 + filterSidebarByPackage(''); 211 + } else if (packages.indexOf(urlPackage) >= 0) { 212 + selectedPackage = urlPackage; 213 + select.value = urlPackage; 214 + filterSidebarByPackage(urlPackage); 215 + } 216 + 217 + select.addEventListener('change', function(e) { 218 + var pkg = e.target.value; 219 + saveSelectedPackage(pkg); 220 + filterSidebarByPackage(pkg); 221 + }); 222 + } 223 + 224 + function filterSidebarByPackage(pkg) { 225 + var container = document.getElementById('sidebar-content'); 226 + if (!container) return; 227 + 228 + var groups = container.querySelectorAll(':scope > .sidebar-group, :scope > .sidebar-link'); 229 + 230 + groups.forEach(function(group) { 231 + if (!pkg) { 232 + group.style.display = ''; 233 + } else { 234 + var id = group.dataset.id || group.textContent.trim(); 235 + group.style.display = (id === pkg) ? '' : 'none'; 236 + } 237 + }); 238 + 239 + if (pkg) { 240 + var matchingGroup = container.querySelector('.sidebar-group[data-id="' + pkg + '"]'); 241 + if (matchingGroup) { 242 + matchingGroup.classList.remove('collapsed'); 243 + sidebarState[pkg] = true; 244 + saveSidebarState(); 245 + } 246 + } 247 + } 248 + 249 + // Highlight element matching URL hash 250 + function highlightHash() { 251 + document.querySelectorAll('.hash-highlight').forEach(function(el) { el.classList.remove('hash-highlight'); }); 252 + 253 + var hash = window.location.hash; 254 + if (hash) { 255 + var target = document.querySelector(hash); 256 + if (target) { 257 + target.classList.add('hash-highlight'); 258 + setTimeout(function() { 259 + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); 260 + }, 50); 261 + } 262 + } 263 + } 264 + 265 + window.addEventListener('hashchange', highlightHash); 266 + 267 + // SPA Navigation 268 + async function navigateTo(url, pushState) { 269 + if (pushState === undefined) pushState = true; 270 + try { 271 + var response = await fetch(ROOT_URL + url); 272 + if (!response.ok) throw new Error('Failed to load page'); 273 + var html = await response.text(); 274 + 275 + var parser = new DOMParser(); 276 + var doc = parser.parseFromString(html, 'text/html'); 277 + 278 + var newContent = doc.querySelector('.odoc-content'); 279 + var newBreadcrumbs = doc.querySelector('.odoc-nav'); 280 + var newToc = doc.querySelector('.odoc-tocs'); 281 + var newTitle = doc.querySelector('title'); 282 + 283 + if (newContent) { 284 + document.querySelector('.odoc-content').innerHTML = newContent.innerHTML; 285 + } 286 + if (newBreadcrumbs) { 287 + document.querySelector('.odoc-nav').innerHTML = newBreadcrumbs.innerHTML; 288 + } 289 + 290 + var existingToc = document.querySelector('.odoc-tocs'); 291 + if (newToc) { 292 + if (existingToc) { 293 + existingToc.outerHTML = newToc.outerHTML; 294 + } else { 295 + document.querySelector('.docsite-main').insertAdjacentHTML('beforeend', newToc.outerHTML); 296 + } 297 + } else if (existingToc) { 298 + existingToc.remove(); 299 + } 300 + 301 + if (newTitle) { 302 + document.title = newTitle.textContent; 303 + } 304 + 305 + CURRENT_URL = url; 306 + if (pushState) { 307 + history.pushState({ url: url }, '', ROOT_URL + url); 308 + } 309 + 310 + updateSidebarActive(); 311 + 312 + var searchResults = document.querySelector('.search-results'); 313 + var searchInput = document.querySelector('.search-input'); 314 + if (searchResults) searchResults.classList.remove('active'); 315 + if (searchInput) searchInput.value = ''; 316 + 317 + var hash = url.indexOf('#') >= 0 ? '#' + url.split('#')[1] : window.location.hash; 318 + if (hash) { 319 + setTimeout(function() { highlightHash(); }, 100); 320 + } else { 321 + document.querySelector('.docsite-main').scrollTop = 0; 322 + window.scrollTo(0, 0); 323 + } 324 + 325 + } catch (e) { 326 + console.error('Navigation failed:', e); 327 + window.location.href = ROOT_URL + url; 328 + } 329 + } 330 + 331 + window.addEventListener('popstate', function(e) { 332 + if (e.state && e.state.url) { 333 + navigateTo(e.state.url, false); 334 + } 335 + }); 336 + 337 + // Intercept link clicks for SPA navigation 338 + document.addEventListener('click', function(e) { 339 + var link = e.target.closest('a[href]'); 340 + if (!link) return; 341 + 342 + var href = link.getAttribute('href'); 343 + if (!href) return; 344 + 345 + if (href.indexOf('http') === 0 || href.indexOf('//') === 0 || href.indexOf('#') === 0 || 346 + href.indexOf('mailto:') === 0 || href.indexOf('javascript:') === 0) { 347 + return; 348 + } 349 + 350 + var navPath = link.dataset.nav; 351 + if (navPath) { 352 + e.preventDefault(); 353 + navigateTo(navPath); 354 + return; 355 + } 356 + 357 + var targetUrl = href; 358 + if (href.indexOf('/') === 0) { 359 + targetUrl = href.slice(1); 360 + } else if (href.indexOf('./') === 0) { 361 + var currentDir = CURRENT_URL.substring(0, CURRENT_URL.lastIndexOf('/') + 1); 362 + targetUrl = currentDir + href.slice(2); 363 + } else if (href.indexOf('../') === 0) { 364 + var currentParts = CURRENT_URL.split('/'); 365 + currentParts.pop(); 366 + var hrefParts = href.split('/'); 367 + for (var i = 0; i < hrefParts.length; i++) { 368 + var part = hrefParts[i]; 369 + if (part === '..') { 370 + currentParts.pop(); 371 + } else if (part !== '.') { 372 + currentParts.push(part); 373 + } 374 + } 375 + targetUrl = currentParts.join('/'); 376 + } else { 377 + var curDir = CURRENT_URL.substring(0, CURRENT_URL.lastIndexOf('/') + 1); 378 + targetUrl = curDir + href; 379 + } 380 + 381 + if (targetUrl.indexOf('#') >= 0) { 382 + var pathAndHash = targetUrl.split('#'); 383 + if (pathAndHash[0] === CURRENT_URL || pathAndHash[0] === '') { 384 + return; 385 + } 386 + } 387 + 388 + e.preventDefault(); 389 + navigateTo(targetUrl); 390 + }); 391 + 392 + // Initialize 393 + (function() { 394 + history.replaceState({ url: CURRENT_URL }, '', window.location.href); 395 + 396 + // Read sidebar data from inline script tag if available 397 + var inlineData = window.__DOCSITE_SIDEBAR_DATA__; 398 + if (inlineData) { 399 + initSidebar(inlineData); 400 + } else { 401 + // Fallback: fetch sidebar.json 402 + fetch(ROOT_URL + 'sidebar.json') 403 + .then(function(r) { return r.json(); }) 404 + .then(function(data) { initSidebar(data); }) 405 + .catch(function(e) { 406 + console.error('Failed to load sidebar:', e); 407 + var el = document.getElementById('sidebar-content'); 408 + if (el) el.innerHTML = '<div class="sidebar-error">Failed to load navigation</div>'; 409 + }); 410 + } 411 + 412 + if (window.location.hash) { 413 + setTimeout(highlightHash, 100); 414 + } 415 + })(); 416 + 417 + // Search functionality 418 + (function() { 419 + var worker; 420 + var waiting = 0; 421 + 422 + var searchInput = document.querySelector('.search-input'); 423 + var searchResults = document.querySelector('.search-results'); 424 + var searchWrapper = document.querySelector('.search-wrapper'); 425 + 426 + if (!searchInput) return; 427 + 428 + function showBusy() { 429 + waiting++; 430 + searchWrapper.classList.add('search-busy'); 431 + } 432 + 433 + function hideBusy() { 434 + if (waiting > 0) waiting--; 435 + if (waiting === 0) { 436 + searchWrapper.classList.remove('search-busy'); 437 + } 438 + } 439 + 440 + function createWorker() { 441 + var searchUrls = ['db.js', 'sherlodoc.js'].map(function(url) { return '"' + ROOT_URL + url + '"'; }); 442 + var blob = new Blob(['importScripts(' + searchUrls.join(',') + ');'], { type: 'application/javascript' }); 443 + var blobUrl = URL.createObjectURL(blob); 444 + var w = new Worker(blobUrl); 445 + URL.revokeObjectURL(blobUrl); 446 + return w; 447 + } 448 + 449 + searchInput.addEventListener('focus', function() { 450 + if (!worker) { 451 + worker = createWorker(); 452 + worker.onmessage = function(e) { 453 + hideBusy(); 454 + var results = e.data; 455 + searchResults.innerHTML = ''; 456 + 457 + if (results.length === 0 && searchInput.value.trim() !== '') { 458 + searchResults.innerHTML = '<div class="search-no-result">No results found</div>'; 459 + } else { 460 + results.forEach(function(entry) { 461 + var link = document.createElement('a'); 462 + link.className = 'search-result'; 463 + link.href = ROOT_URL + entry.url; 464 + link.dataset.nav = entry.url; 465 + link.innerHTML = entry.html; 466 + searchResults.appendChild(link); 467 + }); 468 + } 469 + 470 + if (searchInput.value.trim() !== '') { 471 + searchResults.classList.add('active'); 472 + } 473 + }; 474 + } 475 + }); 476 + 477 + searchInput.addEventListener('input', function(e) { 478 + var query = e.target.value.trim(); 479 + if (query === '') { 480 + searchResults.classList.remove('active'); 481 + searchResults.innerHTML = ''; 482 + return; 483 + } 484 + showBusy(); 485 + worker.postMessage(query); 486 + }); 487 + 488 + document.addEventListener('keydown', function(e) { 489 + if (e.key === '/' && document.activeElement !== searchInput) { 490 + e.preventDefault(); 491 + searchInput.focus(); 492 + } 493 + if (e.key === 'Escape' && document.activeElement === searchInput) { 494 + searchInput.blur(); 495 + searchResults.classList.remove('active'); 496 + } 497 + if (e.key === 'ArrowDown' && searchResults.classList.contains('active')) { 498 + e.preventDefault(); 499 + var items = Array.from(searchResults.querySelectorAll('.search-result')); 500 + var idx = items.findIndex(function(el) { return el === document.activeElement; }); 501 + if (idx < items.length - 1) items[idx + 1].focus(); 502 + else if (idx === -1 && items.length > 0) items[0].focus(); 503 + } 504 + if (e.key === 'ArrowUp' && searchResults.classList.contains('active')) { 505 + e.preventDefault(); 506 + var items2 = Array.from(searchResults.querySelectorAll('.search-result')); 507 + var idx2 = items2.findIndex(function(el) { return el === document.activeElement; }); 508 + if (idx2 > 0) items2[idx2 - 1].focus(); 509 + else if (idx2 === 0) searchInput.focus(); 510 + } 511 + }); 512 + 513 + document.addEventListener('click', function(e) { 514 + if (!searchWrapper.contains(e.target)) searchResults.classList.remove('active'); 515 + }); 516 + })(); 517 + 518 + // Mobile menu toggle 519 + (function() { 520 + var menuToggle = document.querySelector('.menu-toggle'); 521 + var sidebar = document.querySelector('.docsite-sidebar'); 522 + if (menuToggle && sidebar) { 523 + menuToggle.addEventListener('click', function() { sidebar.classList.toggle('open'); }); 524 + } 525 + })(); 526 + |}
+499
odoc-docsite/src/odoc_docsite_shell.ml
··· 1 + (* odoc-docsite: A docs.rs-inspired shell plugin for odoc. 2 + Registers as the "docsite" shell, usable with --shell docsite. *) 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:"docsite" 11 + { 12 + filename = "extensions/docsite.css"; 13 + content = Inline Odoc_docsite_css.css; 14 + }; 15 + Odoc_extension_registry.register_support_file ~prefix:"docsite" 16 + { 17 + filename = "extensions/docsite.js"; 18 + content = Inline Odoc_docsite_js.js; 19 + } 20 + 21 + (* Site title from env var, default "Documentation" *) 22 + let site_title = 23 + match Sys.getenv_opt "ODOC_SITE_TITLE" with 24 + | Some t -> t 25 + | None -> "Documentation" 26 + 27 + (* --- Helpers --- *) 28 + 29 + let file_uri ~config ~url (base : Odoc_html.Types.uri) file = 30 + match base with 31 + | Odoc_html.Types.Absolute uri -> uri ^ "/" ^ file 32 + | Relative uri -> 33 + let page = Url.Path.{ kind = `File; parent = uri; name = file } in 34 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 35 + 36 + (* TyXML helper for generating TOC *) 37 + let html_of_toc toc = 38 + let open Odoc_html.Types in 39 + let rec section (s : toc) = 40 + let link = Html.a ~a:[ Html.a_href s.href ] s.title in 41 + match s.children with [] -> [ link ] | cs -> [ link; sections cs ] 42 + and sections the_sections = 43 + the_sections 44 + |> List.map (fun s -> Html.li (section s)) 45 + |> Html.ul 46 + in 47 + match toc with [] -> [] | _ -> [ sections toc ] 48 + 49 + let toc_section toc = 50 + match toc with 51 + | [] -> [] 52 + | _ -> 53 + [ 54 + Html.div 55 + ~a:[ Html.a_class [ "odoc-tocs" ] ] 56 + [ 57 + Html.nav 58 + ~a:[ Html.a_class [ "odoc-toc"; "odoc-local-toc" ] ] 59 + (Html.div 60 + ~a:[ Html.a_class [ "odoc-toc-title" ] ] 61 + [ Html.txt "On This Page" ] 62 + :: html_of_toc toc); 63 + ]; 64 + ] 65 + 66 + (* Breadcrumbs *) 67 + let html_of_breadcrumbs (breadcrumbs : Odoc_html.Types.breadcrumbs) = 68 + let space = Html.txt " " in 69 + let sep = [ space; Html.entity "#x00BB"; space ] in 70 + let html = 71 + List.concat_map_sep ~sep 72 + ~f:(fun (breadcrumb : Odoc_html.Types.breadcrumb) -> 73 + match breadcrumb.href with 74 + | Some href -> 75 + [ 76 + [ 77 + Html.a 78 + ~a:[ Html.a_href href ] 79 + (breadcrumb.name 80 + :> Html_types.flow5_without_interactive Html.elt list); 81 + ]; 82 + ] 83 + | None -> 84 + [ (breadcrumb.name :> Html_types.nav_content_fun Html.elt list) ]) 85 + breadcrumbs.parents 86 + |> List.flatten 87 + in 88 + let current_name :> Html_types.nav_content_fun Html.elt list = 89 + breadcrumbs.current.name 90 + in 91 + let rest = 92 + if List.is_empty breadcrumbs.parents then current_name 93 + else html @ sep @ current_name 94 + in 95 + (rest :> [< Html_types.nav_content_fun > `A `PCDATA `Wbr ] Html.elt list) 96 + 97 + (* Serialize sidebar data to JSON for inline embedding *) 98 + let sidebar_json_script sidebar_data = 99 + match sidebar_data with 100 + | None -> [] 101 + | Some data -> 102 + let json = Odoc_html.Sidebar.to_json data in 103 + let json_str = Json.to_string json in 104 + [ 105 + Html.script 106 + (Html.txt 107 + (Printf.sprintf "window.__DOCSITE_SIDEBAR_DATA__ = %s;" json_str)); 108 + ] 109 + 110 + (* --- Page assembly --- *) 111 + 112 + let page_creator ~config ~url ~uses_katex ~resources ~sidebar_data ~toc header 113 + breadcrumbs content = 114 + let support_uri = Odoc_html.Config.support_uri config in 115 + let path = Odoc_html.Link.Path.for_printing url in 116 + let file_uri = file_uri ~config ~url in 117 + let docsite_css_uri = file_uri support_uri "extensions/docsite.css" in 118 + let docsite_js_uri = file_uri support_uri "extensions/docsite.js" in 119 + 120 + (* Compute BASE_URL - relative path from current page to root *) 121 + let base_url = 122 + let page = 123 + Url.Path.{ kind = `File; parent = None; name = "" } 124 + in 125 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 126 + in 127 + 128 + (* Current URL as relative path from root *) 129 + let current_url = 130 + let filename = Odoc_html.Link.Path.as_filename ~config url in 131 + Fpath.to_string filename 132 + in 133 + 134 + (* Deduplicate resources *) 135 + let deduplicate_resources resources = 136 + let rec aux seen acc = function 137 + | [] -> List.rev acc 138 + | r :: rest -> 139 + if List.mem r seen then aux seen acc rest 140 + else aux (r :: seen) (r :: acc) rest 141 + in 142 + aux [] [] resources 143 + in 144 + 145 + (* Extension resources *) 146 + let extension_head_elements = 147 + let open Odoc_extension_registry in 148 + let is_absolute_url url = 149 + String.is_prefix ~affix:"http://" url 150 + || String.is_prefix ~affix:"https://" url 151 + in 152 + let resources = deduplicate_resources resources in 153 + List.concat_map 154 + (function 155 + | Js_url js_url -> 156 + let resolved = 157 + if is_absolute_url js_url then js_url 158 + else file_uri support_uri js_url 159 + in 160 + [ Html.script ~a:[ Html.a_src resolved ] (Html.txt "") ] 161 + | Css_url css_url -> 162 + let resolved = 163 + if is_absolute_url css_url then css_url 164 + else file_uri support_uri css_url 165 + in 166 + [ Html.link ~rel:[ `Stylesheet ] ~href:resolved () ] 167 + | Js_inline code -> [ Html.script (Html.cdata_script code) ] 168 + | Css_inline code -> [ Html.style [ Html.cdata_style code ] ]) 169 + resources 170 + in 171 + 172 + (* KaTeX support *) 173 + let katex_elements = 174 + if uses_katex then 175 + let theme_uri = Odoc_html.Config.theme_uri config in 176 + let katex_css_uri = file_uri theme_uri "katex.min.css" in 177 + let katex_js_uri = file_uri support_uri "katex.min.js" in 178 + [ 179 + Html.link ~rel:[ `Stylesheet ] ~href:katex_css_uri (); 180 + Html.script ~a:[ Html.a_src katex_js_uri ] (Html.txt ""); 181 + Html.script 182 + (Html.cdata_script 183 + {| 184 + document.addEventListener("DOMContentLoaded", function () { 185 + var macros = {}; 186 + var elements = Array.from(document.getElementsByClassName("odoc-katex-math")); 187 + for (var i = 0; i < elements.length; i++) { 188 + var el = elements[i]; 189 + var content = el.textContent; 190 + var new_el = document.createElement("span"); 191 + new_el.setAttribute("class", "odoc-katex-math-rendered"); 192 + var display = el.classList.contains("display"); 193 + katex.render(content, new_el, { throwOnError: false, displayMode: display, macros }); 194 + el.replaceWith(new_el); 195 + } 196 + }); 197 + |}); 198 + ] 199 + else [] 200 + in 201 + 202 + let head : Html_types.head Html.elt = 203 + let title_string = 204 + Printf.sprintf "%s (%s)" url.name (String.concat ~sep:"." path) 205 + in 206 + let meta_elements = 207 + [ 208 + Html.meta ~a:[ Html.a_charset "utf-8" ] (); 209 + Html.link ~rel:[ `Stylesheet ] ~href:docsite_css_uri (); 210 + Html.meta 211 + ~a: 212 + [ 213 + Html.a_name "viewport"; 214 + Html.a_content "width=device-width,initial-scale=1.0"; 215 + ] 216 + (); 217 + Html.meta 218 + ~a:[ Html.a_name "generator"; Html.a_content "odoc %%VERSION%%" ] 219 + (); 220 + (* Inject BASE_URL and CURRENT_URL for the JS *) 221 + Html.script 222 + (Html.txt 223 + (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" 224 + base_url current_url)); 225 + ] 226 + @ katex_elements @ extension_head_elements 227 + @ sidebar_json_script sidebar_data 228 + in 229 + Html.head (Html.title (Html.txt title_string)) meta_elements 230 + in 231 + 232 + (* Compute the root-relative href for the brand link *) 233 + let brand_href = 234 + let index_path = 235 + Url.Path.{ kind = `LeafPage; parent = None; name = "index" } 236 + in 237 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path index_path) 238 + in 239 + 240 + let body = 241 + [ 242 + (* Header bar *) 243 + Html.header 244 + ~a:[ Html.a_class [ "docsite-header" ] ] 245 + [ 246 + (* Mobile menu toggle *) 247 + Html.Unsafe.data 248 + {|<button class="menu-toggle" aria-label="Toggle menu"> 249 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 250 + <line x1="3" y1="12" x2="21" y2="12"></line> 251 + <line x1="3" y1="6" x2="21" y2="6"></line> 252 + <line x1="3" y1="18" x2="21" y2="18"></line> 253 + </svg> 254 + </button>|}; 255 + Html.a 256 + ~a: 257 + [ 258 + Html.a_href brand_href; 259 + Html.a_class [ "docsite-header-brand" ]; 260 + ] 261 + [ Html.txt site_title ]; 262 + Html.div 263 + ~a:[ Html.a_class [ "docsite-search-container" ] ] 264 + [ 265 + Html.div 266 + ~a:[ Html.a_class [ "search-wrapper" ] ] 267 + [ 268 + Html.Unsafe.data 269 + {|<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 270 + <circle cx="11" cy="11" r="8"></circle> 271 + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 272 + </svg>|}; 273 + Html.input 274 + ~a: 275 + [ 276 + Html.a_input_type `Text; 277 + Html.a_class [ "search-input" ]; 278 + Html.a_placeholder "Search documentation..."; 279 + ] 280 + (); 281 + Html.span 282 + ~a:[ Html.a_class [ "search-shortcut" ] ] 283 + [ Html.txt "/" ]; 284 + Html.div ~a:[ Html.a_class [ "search-results" ] ] []; 285 + ]; 286 + ]; 287 + ]; 288 + (* Layout: sidebar + main *) 289 + Html.div 290 + ~a:[ Html.a_class [ "docsite-layout" ] ] 291 + [ 292 + (* Sidebar *) 293 + Html.nav 294 + ~a:[ Html.a_class [ "docsite-sidebar" ] ] 295 + [ 296 + Html.div 297 + ~a:[ Html.a_class [ "package-selector" ] ] 298 + [ 299 + Html.Unsafe.data 300 + {|<select id="package-select"> 301 + <option value="">All packages</option> 302 + </select>|}; 303 + ]; 304 + Html.div 305 + ~a: 306 + [ 307 + Html.a_class [ "sidebar-nav" ]; 308 + Html.a_id "sidebar-content"; 309 + ] 310 + [ Html.div ~a:[ Html.a_class [ "sidebar-loading" ] ] [ Html.txt "Loading..." ] ]; 311 + ]; 312 + (* Main content area *) 313 + Html.main 314 + ~a:[ Html.a_class [ "docsite-main" ] ] 315 + ([ Html.nav ~a:[ Html.a_class [ "odoc-nav" ] ] (html_of_breadcrumbs breadcrumbs) ] 316 + @ [ Html.div ~a:[ Html.a_class [ "odoc-content" ] ] 317 + ((header :> Html_types.div_content Html.elt list) 318 + @ content) ] 319 + @ toc_section toc); 320 + ]; 321 + (* Docsite JS *) 322 + Html.script ~a:[ Html.a_src docsite_js_uri; Html.a_defer () ] (Html.txt ""); 323 + ] 324 + in 325 + 326 + let htmlpp = Html.pp ~indent:(Odoc_html.Config.indent config) () in 327 + let html = 328 + Html.html head (Html.body ~a:[ Html.a_class [ "odoc"; "odoc-docsite" ] ] body) 329 + in 330 + let content ppf = 331 + htmlpp ppf html; 332 + Format.pp_force_newline ppf () 333 + in 334 + content 335 + 336 + let make ~config ~url ~header ~breadcrumbs ~sidebar_data ~toc ~uses_katex 337 + ~resources ~assets content children = 338 + let filename = Odoc_html.Link.Path.as_filename ~config url in 339 + let content = 340 + page_creator ~config ~url ~uses_katex ~resources ~sidebar_data ~toc header 341 + breadcrumbs content 342 + in 343 + { Odoc_document.Renderer.filename; content; children; path = url; assets } 344 + 345 + let src_page_creator ~breadcrumbs ~config ~url ~header ~sidebar_data title 346 + content = 347 + let support_uri = Odoc_html.Config.support_uri config in 348 + let file_uri = file_uri ~config ~url in 349 + let docsite_css_uri = file_uri support_uri "extensions/docsite.css" in 350 + let docsite_js_uri = file_uri support_uri "extensions/docsite.js" in 351 + 352 + let base_url = 353 + let page = 354 + Url.Path.{ kind = `File; parent = None; name = "" } 355 + in 356 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path page) 357 + in 358 + let current_url = 359 + let filename = Odoc_html.Link.Path.as_filename ~config url in 360 + Fpath.to_string filename 361 + in 362 + 363 + let brand_href = 364 + let index_path = 365 + Url.Path.{ kind = `LeafPage; parent = None; name = "index" } 366 + in 367 + Odoc_html.Link.href ~config ~resolve:(Current url) (Url.from_path index_path) 368 + in 369 + 370 + let head : Html_types.head Html.elt = 371 + let title_string = Printf.sprintf "Source: %s" title in 372 + let meta_elements = 373 + [ 374 + Html.meta ~a:[ Html.a_charset "utf-8" ] (); 375 + Html.link ~rel:[ `Stylesheet ] ~href:docsite_css_uri (); 376 + Html.meta 377 + ~a: 378 + [ 379 + Html.a_name "viewport"; 380 + Html.a_content "width=device-width,initial-scale=1.0"; 381 + ] 382 + (); 383 + Html.script 384 + (Html.txt 385 + (Printf.sprintf "window.BASE_URL = %S; window.CURRENT_URL = %S;" 386 + base_url current_url)); 387 + ] 388 + @ sidebar_json_script sidebar_data 389 + in 390 + Html.head (Html.title (Html.txt title_string)) meta_elements 391 + in 392 + 393 + let body = 394 + [ 395 + Html.header 396 + ~a:[ Html.a_class [ "docsite-header" ] ] 397 + [ 398 + Html.a 399 + ~a: 400 + [ 401 + Html.a_href brand_href; 402 + Html.a_class [ "docsite-header-brand" ]; 403 + ] 404 + [ Html.txt site_title ]; 405 + Html.div 406 + ~a:[ Html.a_class [ "docsite-search-container" ] ] 407 + [ 408 + Html.div 409 + ~a:[ Html.a_class [ "search-wrapper" ] ] 410 + [ 411 + Html.input 412 + ~a: 413 + [ 414 + Html.a_input_type `Text; 415 + Html.a_class [ "search-input" ]; 416 + Html.a_placeholder "Search documentation..."; 417 + ] 418 + (); 419 + Html.div ~a:[ Html.a_class [ "search-results" ] ] []; 420 + ]; 421 + ]; 422 + ]; 423 + Html.div 424 + ~a:[ Html.a_class [ "docsite-layout" ] ] 425 + [ 426 + Html.nav 427 + ~a:[ Html.a_class [ "docsite-sidebar" ] ] 428 + [ 429 + Html.div 430 + ~a:[ Html.a_class [ "package-selector" ] ] 431 + [ 432 + Html.Unsafe.data 433 + {|<select id="package-select"> 434 + <option value="">All packages</option> 435 + </select>|}; 436 + ]; 437 + Html.div 438 + ~a: 439 + [ 440 + Html.a_class [ "sidebar-nav" ]; 441 + Html.a_id "sidebar-content"; 442 + ] 443 + [ Html.div ~a:[ Html.a_class [ "sidebar-loading" ] ] [ Html.txt "Loading..." ] ]; 444 + ]; 445 + Html.main 446 + ~a:[ Html.a_class [ "docsite-main" ] ] 447 + ([ Html.nav ~a:[ Html.a_class [ "odoc-nav" ] ] (html_of_breadcrumbs breadcrumbs) ] 448 + @ [ Html.header 449 + ~a:[ Html.a_class [ "odoc-preamble" ] ] 450 + (header :> Html_types.flow5_without_header_footer Html.elt list) ] 451 + @ content); 452 + ]; 453 + Html.script ~a:[ Html.a_src docsite_js_uri; Html.a_defer () ] (Html.txt ""); 454 + ] 455 + in 456 + 457 + let htmlpp = Html.pp ~indent:false () in 458 + let html = 459 + Html.html head 460 + (Html.body ~a:[ Html.a_class [ "odoc-src"; "odoc-docsite" ] ] body) 461 + in 462 + let content ppf = 463 + htmlpp ppf html; 464 + Format.pp_force_newline ppf () 465 + in 466 + content 467 + 468 + let make_src ~config ~url ~breadcrumbs ~header ~sidebar_data title content = 469 + let filename = Odoc_html.Link.Path.as_filename ~config url in 470 + let content = 471 + src_page_creator ~breadcrumbs ~config ~url ~header ~sidebar_data title 472 + content 473 + in 474 + { 475 + Odoc_document.Renderer.filename; 476 + content; 477 + children = []; 478 + path = url; 479 + assets = []; 480 + } 481 + 482 + (* Register the shell *) 483 + let () = 484 + Odoc_html.Html_shell.register 485 + (module struct 486 + let name = "docsite" 487 + 488 + let make ~config (data : Odoc_html.Html_shell.page_data) = 489 + make ~config ~url:data.url 490 + ~header:(data.header @ data.preamble) 491 + ~breadcrumbs:data.breadcrumbs ~sidebar_data:data.sidebar_data 492 + ~toc:data.toc ~uses_katex:data.uses_katex ~resources:data.resources 493 + ~assets:data.assets data.content data.children 494 + 495 + let make_src ~config (data : Odoc_html.Html_shell.src_page_data) = 496 + make_src ~config ~url:data.url ~breadcrumbs:data.breadcrumbs 497 + ~header:data.header ~sidebar_data:data.sidebar_data data.title 498 + data.content 499 + end)
+7 -4
odoc/src/html/generator.ml
··· 658 658 let subpages = subpages ~config ~sidebar @@ Doctree.Subpages.compute p in 659 659 let resolve = Link.Current url in 660 660 let breadcrumbs = Breadcrumbs.gen_breadcrumbs ~config ~sidebar ~url in 661 - let sidebar = 661 + let sidebar_html = 662 662 match sidebar with 663 663 | None -> None 664 664 | Some sidebar -> ··· 688 688 | None -> Html_shell.default () 689 689 in 690 690 Shell.make ~config 691 - { url; header; preamble; content; breadcrumbs; toc; sidebar; 691 + { url; header; preamble; content; breadcrumbs; toc; 692 + sidebar = sidebar_html; sidebar_data = sidebar; 692 693 uses_katex; source_anchor; resources; assets; children = subpages } 693 694 694 695 and source_page ~config ~sidebar sp = 695 696 let { Source_page.url; contents } = sp in 696 697 let resolve = Link.Current sp.url in 697 698 let breadcrumbs = Breadcrumbs.gen_breadcrumbs ~config ~sidebar ~url in 698 - let sidebar = 699 + let sidebar_html = 699 700 match sidebar with 700 701 | None -> None 701 702 | Some sidebar -> ··· 717 718 | Some shell -> shell 718 719 | None -> Html_shell.default () 719 720 in 720 - Shell.make_src ~config { url; header; breadcrumbs; sidebar; title; content = [ doc ] } 721 + Shell.make_src ~config 722 + { url; header; breadcrumbs; sidebar = sidebar_html; 723 + sidebar_data = sidebar; title; content = [ doc ] } 721 724 end 722 725 723 726 let render ~config ~sidebar = function
+2
odoc/src/html/html_shell.ml
··· 10 10 breadcrumbs : Types.breadcrumbs; 11 11 toc : Types.toc list; 12 12 sidebar : Html_types.div_content Html.elt list option; 13 + sidebar_data : Odoc_document.Sidebar.t option; 13 14 uses_katex : bool; 14 15 source_anchor : string option; 15 16 resources : Odoc_extension_registry.resource list; ··· 22 23 header : Html_types.flow5_without_header_footer Html.elt list; 23 24 breadcrumbs : Types.breadcrumbs; 24 25 sidebar : Html_types.div_content Html.elt list option; 26 + sidebar_data : Odoc_document.Sidebar.t option; 25 27 title : string; 26 28 content : Html_types.div_content Html.elt list; 27 29 }
+2
odoc/src/html/html_shell.mli
··· 16 16 breadcrumbs : Types.breadcrumbs; 17 17 toc : Types.toc list; 18 18 sidebar : Html_types.div_content Html.elt list option; 19 + sidebar_data : Odoc_document.Sidebar.t option; 19 20 uses_katex : bool; 20 21 source_anchor : string option; 21 22 resources : Odoc_extension_registry.resource list; ··· 29 30 header : Html_types.flow5_without_header_footer Html.elt list; 30 31 breadcrumbs : Types.breadcrumbs; 31 32 sidebar : Html_types.div_content Html.elt list option; 33 + sidebar_data : Odoc_document.Sidebar.t option; 32 34 title : string; 33 35 content : Html_types.div_content Html.elt list; 34 36 }
+3
odoc/src/html/odoc_html.ml
··· 7 7 module Html_page = Html_page 8 8 (** @canonical Odoc_html.Html_page *) 9 9 10 + module Html_shell = Html_shell 11 + (** @canonical Odoc_html.Html_shell *) 12 + 10 13 module Generator = Generator 11 14 module Link = Link 12 15 module Json = Odoc_utils.Json