My agentic slop goes here. Not intended for anyone else!
at main 24 kB view raw
1(* 2 * Copyright (c) 2014, OCaml.org project 3 * Copyright (c) 2015 KC Sivaramakrishnan <sk826@cl.cam.ac.uk> 4 * 5 * Permission to use, copy, modify, and distribute this software for any 6 * purpose with or without fee is hereby granted, provided that the above 7 * copyright notice and this permission notice appear in all copies. 8 * 9 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 *) 17 18(** Feed format conversion and export. *) 19 20module Atom = struct 21 let entry_of_post post = 22 let content = Syndic.Atom.Html (None, Post.content post) in 23 let contributors = 24 [ Syndic.Atom.author ~uri:(Uri.of_string (Source.url (Feed.source (Post.feed post)))) 25 (Source.name (Feed.source (Post.feed post))) ] 26 in 27 let links = 28 match Post.link post with 29 | Some l -> [ Syndic.Atom.link ~rel:Syndic.Atom.Alternate l ] 30 | None -> [] 31 in 32 let id = 33 match Post.link post with 34 | Some l -> l 35 | None -> Uri.of_string (Digest.to_hex (Digest.string (Post.title post))) 36 in 37 let authors = (Syndic.Atom.author ~email:(Post.email post) (Post.author post), []) in 38 let title : Syndic.Atom.text_construct = Syndic.Atom.Text (Post.title post) in 39 let updated = 40 match Post.date post with 41 (* Atom entry requires a date but RSS2 does not. So if a date 42 * is not available, just capture the current date. *) 43 | None -> Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get 44 | Some d -> d 45 in 46 let categories = 47 List.map (fun tag -> Syndic.Atom.category tag) (Post.tags post) 48 in 49 Syndic.Atom.entry ~content ~contributors ~links ~id ~authors ~title ~updated ~categories 50 () 51 52 let entries_of_posts posts = List.map entry_of_post posts 53 54 let feed_of_entries ~title ?id ?(authors = []) entries = 55 let feed_id = match id with 56 | Some i -> Uri.of_string i 57 | None -> Uri.of_string "urn:river:merged" 58 in 59 let feed_authors = List.map (fun (name, email) -> 60 match email with 61 | Some e -> Syndic.Atom.author ~email:e name 62 | None -> Syndic.Atom.author name 63 ) authors in 64 { 65 Syndic.Atom.id = feed_id; 66 title = Syndic.Atom.Text title; 67 updated = Ptime.of_float_s (Unix.time ()) |> Option.get; 68 entries; 69 authors = feed_authors; 70 categories = []; 71 contributors = []; 72 generator = Some { 73 Syndic.Atom.version = Some "1.0"; 74 uri = None; 75 content = "River Feed Aggregator"; 76 }; 77 icon = None; 78 links = []; 79 logo = None; 80 rights = None; 81 subtitle = None; 82 } 83 84 let to_string feed = 85 let output = Buffer.create 4096 in 86 Syndic.Atom.output feed (`Buffer output); 87 Buffer.contents output 88end 89 90module Rss2 = struct 91 let of_feed feed = 92 (* Feed content is now always JSONFeed - cannot extract RSS2 directly *) 93 (* This function is kept for backwards compatibility but always returns None *) 94 let _ = feed in 95 None 96end 97 98module Jsonfeed = struct 99 let item_of_post post = 100 (* Convert HTML content back to string *) 101 let html = Post.content post in 102 let content = `Html html in 103 104 (* Create author *) 105 let authors = 106 if Post.author post <> "" then 107 let author = Jsonfeed.Author.create ~name:(Post.author post) () in 108 Some [author] 109 else 110 None 111 in 112 113 (* Create item *) 114 Jsonfeed.Item.create 115 ~id:(Post.id post) 116 ~content 117 ?url:(Option.map Uri.to_string (Post.link post)) 118 ~title:(Post.title post) 119 ?summary:(Post.summary post) 120 ?date_published:(Post.date post) 121 ?authors 122 ~tags:(Post.tags post) 123 () 124 125 let items_of_posts posts = List.map item_of_post posts 126 127 let feed_of_items ~title ?home_page_url ?feed_url ?description ?icon ?favicon items = 128 Jsonfeed.create ~title ?home_page_url ?feed_url ?description ?icon ?favicon ~items () 129 130 let feed_of_posts ~title ?home_page_url ?feed_url ?description ?icon ?favicon posts = 131 let items = items_of_posts posts in 132 feed_of_items ~title ?home_page_url ?feed_url ?description ?icon ?favicon items 133 134 let to_string ?(minify = false) jsonfeed = 135 match Jsonfeed.to_string ~minify jsonfeed with 136 | Ok s -> Ok s 137 | Error err -> Error (Jsont.Error.to_string err) 138 139 let of_feed feed = 140 (* Feed content is now always River_jsonfeed.t - extract the inner Jsonfeed.t *) 141 let jsonfeed_content = Feed.content feed in 142 Some jsonfeed_content.River_jsonfeed.feed 143end 144 145module Html = struct 146 (** HTML static site generation. *) 147 148 let css = {| 149* { margin: 0; padding: 0; box-sizing: border-box; } 150 151body { 152 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; 153 line-height: 1.5; 154 color: #333; 155 background: #fff; 156 max-width: 900px; 157 margin: 0 auto; 158 padding: 15px; 159} 160 161header { 162 border-bottom: 1px solid #e1e4e8; 163 padding-bottom: 10px; 164 margin-bottom: 20px; 165} 166 167header h1 { 168 font-size: 22px; 169 font-weight: 600; 170 margin-bottom: 6px; 171} 172 173header h1 a { 174 color: #333; 175 text-decoration: none; 176} 177 178nav { 179 font-size: 13px; 180} 181 182nav a { 183 color: #586069; 184 text-decoration: none; 185 margin-right: 12px; 186} 187 188nav a:hover { 189 color: #0366d6; 190} 191 192.post { 193 margin-bottom: 30px; 194 padding-bottom: 25px; 195 border-bottom: 1px solid #e1e4e8; 196 overflow: hidden; 197} 198 199.post:last-child { 200 border-bottom: none; 201} 202 203.author-thumbnail { 204 float: right; 205 width: 64px; 206 height: 64px; 207 border-radius: 50%; 208 object-fit: cover; 209 margin-left: 15px; 210 margin-bottom: 10px; 211 border: 2px solid #e1e4e8; 212 transition: transform 0.2s, box-shadow 0.2s; 213} 214 215.author-thumbnail:hover { 216 transform: scale(1.05); 217 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); 218} 219 220.post-title { 221 font-size: 18px; 222 font-weight: 600; 223 margin-bottom: 4px; 224 line-height: 1.3; 225} 226 227.post-title a { 228 color: #0366d6; 229 text-decoration: none; 230} 231 232.post-title a:hover { 233 text-decoration: underline; 234} 235 236.post-meta-line { 237 font-size: 12px; 238 color: #586069; 239 margin-bottom: 10px; 240} 241 242.post-meta-line a { 243 color: #586069; 244 text-decoration: none; 245 font-weight: 600; 246} 247 248.post-meta-line a:hover { 249 color: #0366d6; 250} 251 252@media (max-width: 768px) { 253 .author-thumbnail { 254 float: none; 255 display: block; 256 margin: 0 auto 10px auto; 257 } 258} 259 260.post-excerpt { 261 font-size: 14px; 262 color: #24292e; 263 line-height: 1.5; 264} 265 266.post-excerpt p { 267 margin-bottom: 8px; 268} 269 270.post-excerpt ul, .post-excerpt ol { 271 margin-left: 20px; 272 margin-bottom: 8px; 273} 274 275.post-excerpt li { 276 margin-bottom: 3px; 277} 278 279.post-excerpt code { 280 background: #f6f8fa; 281 padding: 2px 4px; 282 border-radius: 3px; 283 font-size: 13px; 284} 285 286.post-excerpt img { 287 float: right; 288 width: 35%; 289 max-width: 300px; 290 margin: 0 0 10px 15px; 291 border-radius: 4px; 292 cursor: pointer; 293 transition: opacity 0.2s; 294} 295 296.post-excerpt img:hover { 297 opacity: 0.9; 298} 299 300@media (max-width: 600px) { 301 .post-excerpt img { 302 float: none; 303 width: 100%; 304 max-width: 100%; 305 margin: 10px 0; 306 } 307} 308 309.lightbox { 310 display: none; 311 position: fixed; 312 top: 0; 313 left: 0; 314 width: 100%; 315 height: 100%; 316 background: rgba(0, 0, 0, 0.9); 317 z-index: 1000; 318 cursor: pointer; 319 align-items: center; 320 justify-content: center; 321} 322 323.lightbox.active { 324 display: flex; 325} 326 327.lightbox img { 328 max-width: 95%; 329 max-height: 95%; 330 object-fit: contain; 331} 332 333.post-full-content { 334 display: none; 335 font-size: 14px; 336 color: #24292e; 337 line-height: 1.5; 338 margin-top: 10px; 339} 340 341.post-full-content.active { 342 display: block; 343} 344 345.post-full-content p { 346 margin-bottom: 10px; 347} 348 349.post-full-content ul, .post-full-content ol { 350 margin-left: 20px; 351 margin-bottom: 10px; 352} 353 354.post-full-content li { 355 margin-bottom: 4px; 356} 357 358.post-full-content h1, .post-full-content h2, .post-full-content h3 { 359 margin-top: 15px; 360 margin-bottom: 8px; 361} 362 363.post-full-content h1 { 364 font-size: 18px; 365 font-weight: 600; 366} 367 368.post-full-content h2 { 369 font-size: 16px; 370 font-weight: 600; 371} 372 373.post-full-content h3 { 374 font-size: 15px; 375 font-weight: 600; 376} 377 378.post-full-content code { 379 background: #f6f8fa; 380 padding: 2px 4px; 381 border-radius: 3px; 382 font-size: 13px; 383} 384 385.post-full-content pre { 386 background: #f6f8fa; 387 padding: 10px; 388 border-radius: 4px; 389 overflow-x: auto; 390 margin-bottom: 10px; 391} 392 393.post-full-content pre code { 394 background: none; 395 padding: 0; 396} 397 398.post-full-content blockquote { 399 border-left: 3px solid #e1e4e8; 400 padding-left: 12px; 401 margin: 10px 0; 402 color: #586069; 403} 404 405.post-full-content img { 406 max-width: 100%; 407 height: auto; 408 margin: 10px 0; 409 border-radius: 4px; 410} 411 412.read-more { 413 display: inline-block; 414 color: #0366d6; 415 font-size: 11px; 416 cursor: pointer; 417 text-decoration: none; 418 padding: 2px 8px; 419 border: 1px solid #e1e4e8; 420 border-radius: 3px; 421 background: #f6f8fa; 422 transition: background 0.2s; 423 margin-right: 6px; 424 vertical-align: middle; 425} 426 427.read-more:hover { 428 background: #e1e4e8; 429} 430 431.read-more::after { 432 content: ' ▼'; 433 font-size: 9px; 434} 435 436.read-more.active::after { 437 content: ' ▲'; 438} 439 440.post-tags { 441 margin-top: 8px; 442 font-size: 11px; 443 clear: both; 444 display: inline-block; 445} 446 447.post-tags a { 448 display: inline-block; 449 background: #f1f8ff; 450 color: #0366d6; 451 padding: 2px 6px; 452 border-radius: 3px; 453 text-decoration: none; 454 margin-right: 4px; 455 margin-bottom: 4px; 456 vertical-align: middle; 457 font-size: 11px; 458} 459 460.post-tags a:hover { 461 background: #dbedff; 462} 463 464.post-tags-and-actions { 465 margin-top: 8px; 466 display: flex; 467 align-items: center; 468 clear: both; 469} 470 471.pagination { 472 margin-top: 30px; 473 padding-top: 15px; 474 border-top: 1px solid #e1e4e8; 475 text-align: center; 476 font-size: 13px; 477} 478 479.pagination a { 480 color: #0366d6; 481 text-decoration: none; 482 margin: 0 8px; 483} 484 485.pagination a:hover { 486 text-decoration: underline; 487} 488 489.pagination .current { 490 color: #24292e; 491 font-weight: 600; 492} 493 494.link-item { 495 margin-bottom: 6px; 496 padding: 4px 0; 497 border-bottom: 1px solid #f0f0f0; 498 display: flex; 499 align-items: baseline; 500 font-size: 13px; 501 line-height: 1.4; 502} 503 504.link-item:last-child { 505 border-bottom: none; 506} 507 508.link-url { 509 flex: 0 0 auto; 510 margin-right: 8px; 511} 512 513.link-url a { 514 color: #0366d6; 515 text-decoration: none; 516 font-weight: 500; 517} 518 519.link-url a:hover { 520 text-decoration: underline; 521} 522 523.link-domain { 524 color: #24292e; 525 font-weight: 500; 526} 527 528.link-path { 529 color: #586069; 530 font-weight: 400; 531} 532 533.link-backlinks { 534 flex: 1 1 auto; 535 font-size: 11px; 536 color: #586069; 537 display: flex; 538 flex-wrap: wrap; 539 gap: 6px; 540} 541 542.link-backlink { 543 display: inline-flex; 544 align-items: center; 545 gap: 3px; 546} 547 548.link-backlink a { 549 color: #586069; 550 text-decoration: none; 551} 552 553.link-backlink a:hover { 554 color: #0366d6; 555} 556 557.link-backlink-icon { 558 color: #959da5; 559 font-size: 10px; 560} 561 562.author-list { 563 list-style: none; 564} 565 566.author-item { 567 display: flex; 568 align-items: center; 569 gap: 12px; 570 padding: 12px 0; 571 border-bottom: 1px solid #e1e4e8; 572 transition: background 0.15s; 573} 574 575.author-item:hover { 576 background: #f6f8fa; 577 margin: 0 -8px; 578 padding: 12px 8px; 579} 580 581.author-item-thumbnail { 582 width: 40px; 583 height: 40px; 584 border-radius: 50%; 585 object-fit: cover; 586 flex-shrink: 0; 587 border: 1px solid #e1e4e8; 588} 589 590.author-item-main { 591 flex: 1; 592 min-width: 0; 593} 594 595.author-item-name { 596 font-size: 15px; 597 font-weight: 600; 598 margin-bottom: 2px; 599} 600 601.author-item-name a { 602 color: #24292e; 603 text-decoration: none; 604} 605 606.author-item-name a:hover { 607 color: #0366d6; 608} 609 610.author-item-meta { 611 display: flex; 612 align-items: center; 613 gap: 12px; 614 font-size: 13px; 615 color: #586069; 616 flex-wrap: wrap; 617} 618 619.author-item-username { 620 color: #586069; 621} 622 623.author-item-stat { 624 display: inline-flex; 625 align-items: center; 626 gap: 4px; 627 color: #586069; 628} 629 630.author-item-links { 631 display: flex; 632 align-items: center; 633 gap: 6px; 634} 635 636.author-item-link { 637 display: inline-flex; 638 align-items: center; 639 color: #586069; 640 text-decoration: none; 641 transition: color 0.2s; 642} 643 644.author-item-link:hover { 645 color: #0366d6; 646} 647 648.author-item-link svg { 649 width: 16px; 650 height: 16px; 651} 652 653.author-header { 654 background: #f6f8fa; 655 border: 1px solid #e1e4e8; 656 border-radius: 6px; 657 padding: 24px; 658 margin-bottom: 24px; 659} 660 661.author-header-main { 662 display: flex; 663 align-items: start; 664 gap: 20px; 665 margin-bottom: 20px; 666} 667 668.author-header-thumbnail { 669 width: 96px; 670 height: 96px; 671 border-radius: 50%; 672 object-fit: cover; 673 border: 3px solid #fff; 674 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 675 flex-shrink: 0; 676} 677 678.author-header-info { 679 flex: 1; 680} 681 682.author-header-name { 683 font-size: 28px; 684 font-weight: 700; 685 color: #24292e; 686 margin-bottom: 6px; 687} 688 689.author-header-username { 690 font-size: 16px; 691 color: #586069; 692 margin-bottom: 12px; 693} 694 695.author-header-bio { 696 font-size: 14px; 697 color: #586069; 698 line-height: 1.5; 699 margin-bottom: 12px; 700} 701 702.author-header-links { 703 display: flex; 704 flex-wrap: wrap; 705 gap: 10px; 706} 707 708.author-header-link { 709 display: inline-flex; 710 align-items: center; 711 padding: 6px 12px; 712 background: #fff; 713 border: 1px solid #e1e4e8; 714 border-radius: 4px; 715 font-size: 13px; 716 color: #586069; 717 text-decoration: none; 718 transition: all 0.2s; 719} 720 721.author-header-link:hover { 722 border-color: #0366d6; 723 color: #0366d6; 724} 725 726.author-header-stats { 727 display: flex; 728 gap: 24px; 729 padding-top: 16px; 730 border-top: 1px solid #e1e4e8; 731} 732 733.author-header-stat { 734 display: flex; 735 flex-direction: column; 736} 737 738.author-header-stat-value { 739 font-size: 24px; 740 font-weight: 700; 741 color: #0366d6; 742} 743 744.author-header-stat-label { 745 font-size: 12px; 746 color: #586069; 747 text-transform: uppercase; 748 letter-spacing: 0.5px; 749} 750 751.category-list { 752 list-style: none; 753} 754 755.category-list li { 756 margin-bottom: 12px; 757 padding-bottom: 12px; 758 border-bottom: 1px solid #e1e4e8; 759} 760 761.category-list li:last-child { 762 border-bottom: none; 763} 764 765.category-list a { 766 color: #0366d6; 767 text-decoration: none; 768 font-size: 15px; 769} 770 771.category-list a:hover { 772 text-decoration: underline; 773} 774 775.count { 776 color: #586069; 777 font-size: 12px; 778 margin-left: 6px; 779} 780 781footer { 782 margin-top: 40px; 783 padding-top: 15px; 784 border-top: 1px solid #e1e4e8; 785 text-align: center; 786 font-size: 11px; 787 color: #586069; 788} 789|} 790 791 let html_escape s = 792 let buf = Buffer.create (String.length s) in 793 String.iter (function 794 | '<' -> Buffer.add_string buf "&lt;" 795 | '>' -> Buffer.add_string buf "&gt;" 796 | '&' -> Buffer.add_string buf "&amp;" 797 | '"' -> Buffer.add_string buf "&quot;" 798 | '\'' -> Buffer.add_string buf "&#39;" 799 | c -> Buffer.add_char buf c 800 ) s; 801 Buffer.contents buf 802 803 let format_date date = 804 let open Unix in 805 let tm = gmtime (Ptime.to_float_s date) in 806 let months = [|"January"; "February"; "March"; "April"; "May"; "June"; 807 "July"; "August"; "September"; "October"; "November"; "December"|] in 808 Printf.sprintf "%s %d, %d" months.(tm.tm_mon) tm.tm_mday (1900 + tm.tm_year) 809 810 let page_template ~title ~nav_current content = 811 Printf.sprintf {|<!DOCTYPE html> 812<html lang="en"> 813<head> 814 <meta charset="UTF-8"> 815 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 816 <title>%s</title> 817 <style>%s</style> 818</head> 819<body> 820 <header> 821 <h1><a href="index.html">River Feed</a></h1> 822 <nav> 823 <a href="index.html"%s>Posts</a> 824 <a href="authors/index.html"%s>Authors</a> 825 <a href="categories/index.html"%s>Categories</a> 826 <a href="links.html"%s>Links</a> 827 </nav> 828 </header> 829 <main> 830%s 831 </main> 832 <footer> 833 Generated by River Feed Aggregator 834 </footer> 835 <div class="lightbox" id="lightbox"> 836 <img id="lightbox-img" src="" alt=""> 837 </div> 838 <script> 839 (function() { 840 const lightbox = document.getElementById('lightbox'); 841 const lightboxImg = document.getElementById('lightbox-img'); 842 843 // Add click handler to all images in excerpts and full content 844 document.addEventListener('click', function(e) { 845 if (e.target.tagName === 'IMG' && (e.target.closest('.post-excerpt') || e.target.closest('.post-full-content'))) { 846 e.preventDefault(); 847 lightboxImg.src = e.target.src; 848 lightboxImg.alt = e.target.alt; 849 lightbox.classList.add('active'); 850 } 851 }); 852 853 // Close lightbox on click 854 lightbox.addEventListener('click', function() { 855 lightbox.classList.remove('active'); 856 lightboxImg.src = ''; 857 }); 858 859 // Close on escape key 860 document.addEventListener('keydown', function(e) { 861 if (e.key === 'Escape' && lightbox.classList.contains('active')) { 862 lightbox.classList.remove('active'); 863 lightboxImg.src = ''; 864 } 865 }); 866 867 // Read more toggle 868 document.addEventListener('click', function(e) { 869 if (e.target.classList.contains('read-more')) { 870 e.preventDefault(); 871 const post = e.target.closest('.post'); 872 const fullContent = post.querySelector('.post-full-content'); 873 const excerpt = post.querySelector('.post-excerpt'); 874 875 if (fullContent.classList.contains('active')) { 876 fullContent.classList.remove('active'); 877 excerpt.style.display = 'block'; 878 e.target.textContent = 'Read more'; 879 e.target.classList.remove('active'); 880 } else { 881 fullContent.classList.add('active'); 882 excerpt.style.display = 'none'; 883 e.target.textContent = 'Show less'; 884 e.target.classList.add('active'); 885 } 886 } 887 }); 888 })(); 889 </script> 890</body> 891</html>|} 892 (html_escape title) 893 css 894 (if nav_current = "posts" then " class=\"current\"" else "") 895 (if nav_current = "authors" then " class=\"current\"" else "") 896 (if nav_current = "categories" then " class=\"current\"" else "") 897 (if nav_current = "links" then " class=\"current\"" else "") 898 content 899 900 let pagination_html ~current_page ~total_pages ~base_path = 901 if total_pages <= 1 then "" 902 else 903 let prev = if current_page > 1 then 904 let prev_page = current_page - 1 in 905 let href = if prev_page = 1 then base_path ^ "index.html" 906 else Printf.sprintf "%spage-%d.html" base_path prev_page in 907 Printf.sprintf {|<a href="%s">← Previous</a>|} href 908 else "" 909 in 910 let next = if current_page < total_pages then 911 Printf.sprintf {|<a href="%spage-%d.html">Next →</a>|} base_path (current_page + 1) 912 else "" 913 in 914 let pages = 915 let buf = Buffer.create 256 in 916 for i = 1 to total_pages do 917 if i = current_page then 918 Buffer.add_string buf (Printf.sprintf {| <span class="current">%d</span>|} i) 919 else 920 let href = if i = 1 then base_path ^ "index.html" 921 else Printf.sprintf "%spage-%d.html" base_path i in 922 Buffer.add_string buf (Printf.sprintf {| <a href="%s">%d</a>|} href i) 923 done; 924 Buffer.contents buf 925 in 926 Printf.sprintf {|<div class="pagination">%s%s%s</div>|} prev pages next 927 928 let full_content_from_html html_content = 929 (* Convert HTML to markdown then to clean HTML using Cmarkit *) 930 let markdown = Html_markdown.html_to_markdown html_content in 931 let doc = Cmarkit.Doc.of_string markdown in 932 Cmarkit_html.of_doc ~safe:true doc 933 934 let post_excerpt_from_html html_content ~max_length = 935 (* Convert HTML to markdown for excerpt *) 936 let markdown = Html_markdown.html_to_markdown html_content in 937 (* Find paragraph break after max_length *) 938 let excerpt_md = 939 if String.length markdown <= max_length then markdown 940 else 941 (* Look for double newline (paragraph break) after max_length *) 942 let start_search = min max_length (String.length markdown - 1) in 943 let rec find_para_break pos = 944 if pos >= String.length markdown - 1 then 945 String.length markdown 946 else if pos < String.length markdown - 1 && 947 markdown.[pos] = '\n' && markdown.[pos + 1] = '\n' then 948 pos 949 else 950 find_para_break (pos + 1) 951 in 952 let break_pos = find_para_break start_search in 953 let truncated = String.sub markdown 0 break_pos in 954 if break_pos < String.length markdown then 955 truncated ^ "..." 956 else 957 truncated 958 in 959 (* Convert markdown back to HTML using Cmarkit with custom renderer *) 960 let doc = Cmarkit.Doc.of_string excerpt_md in 961 962 (* Custom renderer that makes headings smaller and strips images *) 963 let excerpt_customizations = 964 let block c = function 965 | Cmarkit.Block.Heading (h, _) -> 966 let level = Cmarkit.Block.Heading.level h in 967 let inline = Cmarkit.Block.Heading.inline h in 968 (* Render heading as a strong tag with smaller font *) 969 let style = match level with 970 | 1 -> "font-size: 15px; font-weight: 600;" 971 | 2 -> "font-size: 14px; font-weight: 600;" 972 | _ -> "font-size: 14px; font-weight: 500;" 973 in 974 Cmarkit_renderer.Context.string c (Printf.sprintf "<strong style=\"%s\">" style); 975 Cmarkit_renderer.Context.inline c inline; 976 Cmarkit_renderer.Context.string c "</strong> "; 977 true 978 | _ -> false 979 in 980 let inline _c = function 981 | Cmarkit.Inline.Image _ -> 982 (* Skip images in excerpts *) 983 true 984 | _ -> false 985 in 986 Cmarkit_renderer.make ~block ~inline () 987 in 988 989 let renderer = Cmarkit_renderer.compose (Cmarkit_html.renderer ~safe:true ()) excerpt_customizations in 990 Cmarkit_renderer.doc_to_string renderer doc 991 992 let render_post_html ~post ~author_username = 993 let title = Post.title post in 994 let author = Post.author post in 995 let date_str = match Post.date post with 996 | Some d -> format_date d 997 | None -> "No date" 998 in 999 let link_html = match Post.link post with 1000 | Some uri -> 1001 Printf.sprintf {|<a href="%s">%s</a>|} 1002 (html_escape (Uri.to_string uri)) 1003 (html_escape title) 1004 | None -> html_escape title 1005 in 1006 let excerpt = post_excerpt_from_html (Post.content post) ~max_length:300 in 1007 let tags_html = 1008 match Post.tags post with 1009 | [] -> "" 1010 | tags -> 1011 let tag_links = List.map (fun tag -> 1012 Printf.sprintf {|<a href="../categories/%s.html">%s</a>|} 1013 (html_escape tag) (html_escape tag) 1014 ) tags in 1015 Printf.sprintf {|<div class="post-tags">%s</div>|} 1016 (String.concat "" tag_links) 1017 in 1018 Printf.sprintf {|<article class="post"> 1019 <h2 class="post-title">%s</h2> 1020 <div class="post-meta"> 1021 By <a href="../authors/%s.html">%s</a> on %s 1022 </div> 1023 <div class="post-excerpt"> 1024%s 1025 </div> 1026%s 1027</article>|} 1028 link_html 1029 (html_escape author_username) 1030 (html_escape author) 1031 date_str 1032 excerpt 1033 tags_html 1034 1035 let render_posts_page ~title ~posts ~current_page ~total_pages ~base_path ~nav_current = 1036 let posts_html = String.concat "\n" posts in 1037 let pagination = pagination_html ~current_page ~total_pages ~base_path in 1038 let content = posts_html ^ "\n" ^ pagination in 1039 page_template ~title ~nav_current content 1040end