Odoc plugins for jon.recoil.org
0
fork

Configure Feed

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

WIP: site redesign, odoc extension API updates, and new content

- odoc extension API: add link-phase enrichment support for extensions
- odoc-jons-plugins: new site shell with nav bar, recent posts with
featured card layout, and updated CSS
- site-builder: support projects/ directory and improved rule generation
- site: redesigned homepage with hero section and recent posts cards
- site: updated blog index layout
- onnxrt docs: minor .mld cleanup
- New blog images and content (weeknotes, examination map, fungus svg)
- Add mockup.html for design iteration

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

+423 -8
+383 -1
src/odoc_jons_plugins.ml
··· 387 387 let hidden_tag_extension prefix = 388 388 let module E = struct 389 389 let prefix = prefix 390 - 391 390 let to_document ~tag:_ _content = 392 391 Api.simple_output [] 393 392 end in ··· 395 394 396 395 let () = 397 396 List.iter hidden_tag_extension [ "published"; "notanotebook"; "packages" ] 397 + 398 + (* --- Recent posts extension --- *) 399 + 400 + module Recent_posts = struct 401 + open Odoc_document.Types 402 + 403 + let prefix = "recent-posts" 404 + 405 + (* Check if a string matches YYYY-MM-DD date pattern *) 406 + let is_date s = 407 + let s = Stdlib.String.trim s in 408 + Stdlib.String.length s = 10 409 + && s.[4] = '-' && s.[7] = '-' 410 + && (try 411 + let _ = int_of_string (Stdlib.String.sub s 0 4) in 412 + let _ = int_of_string (Stdlib.String.sub s 5 2) in 413 + let _ = int_of_string (Stdlib.String.sub s 8 2) in 414 + true 415 + with _ -> false) 416 + 417 + (* Format "2025-12-15" -> "Dec 2025" *) 418 + let format_date s = 419 + let s = Stdlib.String.trim s in 420 + let year = Stdlib.String.sub s 0 4 in 421 + let month = Stdlib.String.sub s 5 2 in 422 + let m = match month with 423 + | "01" -> "Jan" | "02" -> "Feb" | "03" -> "Mar" | "04" -> "Apr" 424 + | "05" -> "May" | "06" -> "Jun" | "07" -> "Jul" | "08" -> "Aug" 425 + | "09" -> "Sep" | "10" -> "Oct" | "11" -> "Nov" | "12" -> "Dec" 426 + | _ -> month 427 + in 428 + m ^ " " ^ year 429 + 430 + (* Find the first unordered list in Block.t *) 431 + let find_list (blocks : Block.t) = 432 + List.find_map (fun (b : Block.one) -> 433 + match b.desc with 434 + | List (Unordered, items) -> Some items 435 + | _ -> None 436 + ) blocks 437 + 438 + (* Extract the link inline element from a list of inlines *) 439 + let find_link (inlines : Inline.t) = 440 + List.find_map (fun (i : Inline.one) -> 441 + match i.desc with 442 + | Link _ -> Some i 443 + | _ -> None 444 + ) inlines 445 + 446 + (* Extract date string from trailing text in inlines *) 447 + let find_date (inlines : Inline.t) = 448 + let texts = List.filter_map (fun (i : Inline.one) -> 449 + match i.desc with 450 + | Text s when is_date s -> Some (Stdlib.String.trim s) 451 + | _ -> None 452 + ) inlines in 453 + match texts with 454 + | [] -> None 455 + | _ -> Some (List.hd (List.rev texts)) 456 + 457 + (* Extract link and date from inlines *) 458 + let extract_from_inlines inlines = 459 + match find_link inlines, find_date inlines with 460 + | Some link, Some date -> Some (link, date) 461 + | Some link, None -> Some (link, "") 462 + | _ -> None 463 + 464 + (* Extract link and date from a list item (Block.t) *) 465 + let extract_item (item : Block.t) = 466 + List.find_map (fun (b : Block.one) -> 467 + match b.desc with 468 + | Paragraph inlines | Inline inlines -> extract_from_inlines inlines 469 + | _ -> None 470 + ) item 471 + 472 + (* Extract an optional excerpt Block.t from a list item. 473 + The link phase injects synopsis paragraphs as extra elements in list items. 474 + We look for a second paragraph (after the one containing the link/date). *) 475 + let extract_excerpt (item : Block.t) = 476 + let paragraphs = List.filter (fun (b : Block.one) -> 477 + match b.desc with 478 + | Paragraph _ -> true 479 + | _ -> false 480 + ) item in 481 + match paragraphs with 482 + | _ :: excerpt :: _ -> Some excerpt 483 + | _ -> None 484 + 485 + let raw html = Block.{ attr = []; desc = Raw_markup ("html", html) } 486 + 487 + let inline_block (inlines : Inline.t) = 488 + Block.{ attr = []; desc = Inline inlines } 489 + 490 + let recent_posts_css = {| 491 + /* Recent posts extension - neutralize at-tags wrapper */ 492 + .jon-shell-main ul.at-tags:has(li.recent-posts) { 493 + margin: 0; 494 + padding: 0; 495 + list-style: none; 496 + margin-left: 0; 497 + } 498 + .jon-shell-main .at-tags li.recent-posts { 499 + list-style: none; 500 + padding: 0; 501 + padding-left: 0; 502 + margin: 0; 503 + text-indent: 0; 504 + } 505 + .recent-posts { 506 + margin: 1.5rem 0 2rem; 507 + } 508 + .recent-posts-header { 509 + display: flex; 510 + align-items: baseline; 511 + justify-content: space-between; 512 + margin-bottom: 1.25rem; 513 + } 514 + .recent-posts-title { 515 + font-size: 1.35rem; 516 + font-weight: 500; 517 + color: var(--text-color); 518 + letter-spacing: -0.01em; 519 + } 520 + .recent-posts-link { 521 + font-size: 0.85rem; 522 + color: var(--text-muted); 523 + } 524 + .recent-posts-link:hover { 525 + color: var(--link-color); 526 + } 527 + .recent-posts-featured { 528 + background: var(--surface-color, #fff); 529 + border: 1px solid var(--border-color); 530 + border-radius: 10px; 531 + padding: 1.5rem 1.75rem; 532 + margin-bottom: 1.25rem; 533 + transition: box-shadow 0.2s ease; 534 + } 535 + .recent-posts-featured:hover { 536 + box-shadow: 0 2px 12px rgba(0,0,0,0.06); 537 + } 538 + .recent-posts-featured-label { 539 + font-size: 0.72rem; 540 + font-weight: 600; 541 + letter-spacing: 0.08em; 542 + text-transform: uppercase; 543 + color: var(--accent-color, #b44e2d); 544 + margin-bottom: 0.5rem; 545 + } 546 + .recent-posts-featured-title { 547 + font-size: 1.35rem; 548 + font-weight: 500; 549 + line-height: 1.3; 550 + margin-bottom: 0.5rem; 551 + } 552 + .recent-posts-featured-title a { 553 + color: var(--text-color); 554 + text-decoration: none; 555 + } 556 + .recent-posts-featured-title a:hover { 557 + color: var(--accent-color, #b44e2d); 558 + } 559 + .recent-posts-featured-date { 560 + font-size: 0.8rem; 561 + color: var(--text-muted); 562 + } 563 + .recent-posts-featured-excerpt { 564 + font-size: 0.92rem; 565 + line-height: 1.55; 566 + color: var(--text-muted); 567 + margin: 0.75rem 0 0; 568 + display: -webkit-box; 569 + -webkit-line-clamp: 3; 570 + -webkit-box-orient: vertical; 571 + overflow: hidden; 572 + } 573 + .recent-posts-list { 574 + display: flex; 575 + flex-direction: column; 576 + gap: 1px; 577 + background: var(--border-color); 578 + border-radius: 10px; 579 + overflow: hidden; 580 + border: 1px solid var(--border-color); 581 + } 582 + .recent-posts-item { 583 + display: grid; 584 + grid-template-columns: 1fr auto; 585 + gap: 1rem; 586 + align-items: baseline; 587 + padding: 0.875rem 1.25rem; 588 + background: var(--surface-color, #fff); 589 + transition: background 0.15s ease; 590 + } 591 + .recent-posts-item:hover { 592 + background: var(--bg-hover, #f0f2f5); 593 + } 594 + .recent-posts-item-title { 595 + font-size: 1rem; 596 + font-weight: 400; 597 + line-height: 1.4; 598 + } 599 + .recent-posts-item-title a { 600 + color: var(--text-color); 601 + text-decoration: none; 602 + } 603 + .recent-posts-item-title a:hover { 604 + color: var(--accent-color, #b44e2d); 605 + } 606 + .recent-posts-item-date { 607 + font-size: 0.78rem; 608 + color: var(--text-muted); 609 + white-space: nowrap; 610 + font-variant-numeric: tabular-nums; 611 + } 612 + @media (prefers-color-scheme: dark) { 613 + .recent-posts-featured { 614 + background: var(--surface-color, #22242a); 615 + } 616 + .recent-posts-featured:hover { 617 + box-shadow: 0 2px 12px rgba(0,0,0,0.2); 618 + } 619 + .recent-posts-item { 620 + background: var(--surface-color, #22242a); 621 + } 622 + .recent-posts-item:hover { 623 + background: var(--bg-hover, #2a2d35); 624 + } 625 + } 626 + @media (max-width: 700px) { 627 + .recent-posts-item { 628 + grid-template-columns: 1fr; 629 + gap: 0.25rem; 630 + } 631 + .recent-posts-featured { 632 + padding: 1.25rem; 633 + } 634 + } 635 + |} 636 + 637 + module Identifier = Odoc_model.Paths.Identifier 638 + 639 + (* Build a path hierarchy from a page identifier, for use with Env.lookup_page_by_path. 640 + E.g. LeafPage(Page(Page(Page(None, "jon-site"), "blog"), "2026"), "weeknotes-2026-09") 641 + -> (`TAbsolutePath, ["jon-site"; "blog"; "2026"; "weeknotes-2026-09"]) *) 642 + let rec hierarchy_of_page_id (id : Identifier.Page.t) : string list = 643 + match id.iv with 644 + | `Page (Some parent, name) | `LeafPage (Some parent, name) -> 645 + hierarchy_of_container_id parent @ [Odoc_model.Names.PageName.to_string name] 646 + | `Page (None, name) | `LeafPage (None, name) -> 647 + [Odoc_model.Names.PageName.to_string name] 648 + 649 + and hierarchy_of_container_id (id : Identifier.ContainerPage.t) : string list = 650 + match id.iv with 651 + | `Page (Some parent, name) -> 652 + hierarchy_of_container_id parent @ [Odoc_model.Names.PageName.to_string name] 653 + | `Page (None, name) -> 654 + [Odoc_model.Names.PageName.to_string name] 655 + 656 + (* Extract page identifier from a resolved reference in a list item's content *) 657 + let extract_page_id_from_item 658 + (item : Api.Comment.nestable_block_element Api.Location_.with_location list) = 659 + List.find_map (fun (el : Api.Comment.nestable_block_element Api.Location_.with_location) -> 660 + match el.Api.Location_.value with 661 + | `Paragraph inlines -> 662 + List.find_map (fun (inline : Api.Comment.inline_element Api.Location_.with_location) -> 663 + match inline.Api.Location_.value with 664 + | `Reference (`Resolved r, _) -> ( 665 + match Odoc_model.Paths.Reference.Resolved.identifier r with 666 + | Some id -> ( 667 + match (id : Identifier.t).iv with 668 + | `LeafPage _ as iv -> 669 + Some ({ iv; ihash = id.ihash; ikey = id.ikey } : Identifier.Page.t) 670 + | `Page _ as iv -> 671 + Some ({ iv; ihash = id.ihash; ikey = id.ikey } : Identifier.Page.t) 672 + | _ -> None) 673 + | None -> None) 674 + | _ -> None 675 + ) inlines 676 + | _ -> None 677 + ) item 678 + 679 + (* Link phase: enrich list items with page synopses from the environment. 680 + For each list item that contains a page reference, look up the page 681 + and inject its synopsis as an additional paragraph. *) 682 + let link ~tag:_ env content = 683 + let enrich_item item = 684 + match extract_page_id_from_item item with 685 + | None -> item 686 + | Some page_id -> 687 + let segments = hierarchy_of_page_id page_id in 688 + let hierarchy : Odoc_model.Paths.Reference.Hierarchy.t = 689 + (`TCurrentPackage, segments) 690 + in 691 + (match Api.Env.lookup_page_by_path hierarchy env with 692 + | Error _ -> item 693 + | Ok page -> 694 + (* Find first paragraph, skipping headings and tags *) 695 + let synopsis = 696 + List.find_map (fun (el : Odoc_model.Comment.block_element Api.Location_.with_location) -> 697 + match el.Api.Location_.value with 698 + | `Paragraph p -> Some p 699 + | `Heading _ | `Tag _ -> None 700 + | _ -> None 701 + ) page.content.elements 702 + in 703 + match synopsis with 704 + | None -> item 705 + | Some synopsis_paragraph -> 706 + let dummy_loc = { 707 + Api.Location_.file = ""; start = { line = 0; column = 0 }; 708 + end_ = { line = 0; column = 0 } 709 + } in 710 + item @ [ { Api.Location_.value = `Paragraph synopsis_paragraph; 711 + location = dummy_loc } ]) 712 + in 713 + List.map (fun (el : Api.Comment.nestable_block_element Api.Location_.with_location) -> 714 + match el.Api.Location_.value with 715 + | `List (kind, items) -> 716 + let enriched_items = List.map enrich_item items in 717 + { el with value = `List (kind, enriched_items) } 718 + | _ -> el 719 + ) content 720 + 721 + let to_document ~tag:_ content = 722 + let blocks = Api.blocks_of_nestable_elements content in 723 + match find_list blocks with 724 + | None -> Api.simple_output blocks 725 + | Some items -> 726 + let parsed = List.filter_map extract_item items in 727 + (* Extract excerpts from the first list item (featured) *) 728 + let featured_excerpt = match items with 729 + | first_item :: _ -> extract_excerpt first_item 730 + | [] -> None 731 + in 732 + match parsed with 733 + | [] -> Api.simple_output blocks 734 + | (featured_link, featured_date) :: rest -> 735 + let result = ref [] in 736 + let add b = result := b :: !result in 737 + (* Section open + header *) 738 + 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>|}); 739 + (* Featured card *) 740 + add (raw {|<div class="recent-posts-featured"><div class="recent-posts-featured-label">Featured</div><div class="recent-posts-featured-title">|}); 741 + add (inline_block [featured_link]); 742 + let date_html = 743 + if featured_date = "" then "" 744 + else Printf.sprintf {|<div class="recent-posts-featured-date">%s</div>|} (format_date featured_date) 745 + in 746 + add (raw (Printf.sprintf {|</div>%s|} date_html)); 747 + (* Excerpt from referenced page *) 748 + (match featured_excerpt with 749 + | Some excerpt_block -> 750 + (match excerpt_block.desc with 751 + | Paragraph inlines -> 752 + add (raw {|<p class="recent-posts-featured-excerpt">|}); 753 + add (inline_block inlines); 754 + add (raw {|</p>|}) 755 + | _ -> ()) 756 + | None -> ()); 757 + add (raw {|</div>|}); 758 + (* Post list *) 759 + if rest <> [] then begin 760 + add (raw {|<div class="recent-posts-list">|}); 761 + List.iter (fun (link, date) -> 762 + add (raw {|<div class="recent-posts-item"><div class="recent-posts-item-title">|}); 763 + add (inline_block [link]); 764 + let date_str = if date = "" then "" else format_date date in 765 + add (raw (Printf.sprintf {|</div><span class="recent-posts-item-date">%s</span></div>|} date_str)) 766 + ) rest; 767 + add (raw {|</div>|}) 768 + end; 769 + (* Section close *) 770 + add (raw {|</div>|}); 771 + { 772 + content = List.rev !result; 773 + overrides = []; 774 + resources = [Css_inline recent_posts_css]; 775 + assets = []; 776 + } 777 + end 778 + 779 + let () = Api.Registry.register_with_link (module Recent_posts)
+40 -7
src/odoc_jons_plugins_css.ml
··· 8 8 --bg-color: #ffffff; 9 9 --text-color: #1a1a2e; 10 10 --text-muted: #6b7280; 11 - --link-color: #0969da; 12 - --link-hover: #0550ae; 11 + --link-color: #b44e2d; 12 + --link-hover: #943f24; 13 13 --border-color: #e5e7eb; 14 14 --code-bg: #f6f8fa; 15 15 --code-border: #e5e7eb; 16 16 --header-bg: #ffffff; 17 - --highlight-bg: rgba(9, 105, 218, 0.08); 17 + --highlight-bg: rgba(180, 78, 45, 0.08); 18 18 19 19 /* x-ocaml interactive cells */ 20 20 --xo-font-size: 0.875rem; ··· 23 23 --xo-gutter-bg: var(--code-bg); 24 24 --xo-gutter-text: var(--text-muted); 25 25 --xo-gutter-border: var(--border-color); 26 - --xo-stdout-bg: rgba(9, 105, 218, 0.06); 26 + --xo-stdout-bg: rgba(180, 78, 45, 0.06); 27 27 --xo-stdout-text: var(--link-color); 28 28 --xo-stderr-bg: rgba(218, 9, 9, 0.06); 29 29 --xo-stderr-text: #cf222e; ··· 44 44 --bg-color: #0d1117; 45 45 --text-color: #e6edf3; 46 46 --text-muted: #8b949e; 47 - --link-color: #58a6ff; 48 - --link-hover: #79c0ff; 47 + --link-color: #e07850; 48 + --link-hover: #f09070; 49 49 --border-color: #30363d; 50 50 --code-bg: #161b22; 51 51 --code-border: #30363d; 52 52 --header-bg: #161b22; 53 - --highlight-bg: rgba(88, 166, 255, 0.12); 53 + --highlight-bg: rgba(224, 120, 80, 0.12); 54 54 55 55 /* x-ocaml interactive cells - dark overrides */ 56 56 --xo-stdout-bg: rgba(88, 166, 255, 0.08); ··· 94 94 margin: 0 auto; 95 95 padding: 16px 20px; 96 96 font-size: 14px; 97 + border-bottom: 1px solid var(--border-color); 97 98 } 98 99 99 100 .jon-shell-header > a { ··· 263 264 .jon-shell-sidebar > ul > li > ul > li > a, 264 265 .jon-shell-sidebar > ul > li > ul > li > .sidebar-label { 265 266 display: none; 267 + } 268 + 269 + /* Hero intro - side-by-side text + photo */ 270 + .hero-intro { 271 + display: grid; 272 + grid-template-columns: 1fr auto; 273 + gap: 2rem; 274 + align-items: start; 275 + margin-bottom: 0.5rem; 276 + } 277 + .hero-intro .hero-text { 278 + min-width: 0; 279 + } 280 + .hero-intro .hero-text > p { 281 + margin-top: 0; 282 + } 283 + .hero-photo { 284 + width: 120px; 285 + height: 120px; 286 + border-radius: 50%; 287 + object-fit: cover; 288 + box-shadow: 0 0 0 1px var(--border-color); 289 + margin-top: 4px; 290 + } 291 + @media (max-width: 700px) { 292 + .hero-intro { 293 + grid-template-columns: 1fr; 294 + gap: 1rem; 295 + } 296 + .hero-intro .hero-photo { 297 + order: -1; 298 + } 266 299 } 267 300 268 301 /* Typography */