My agentic slop goes here. Not intended for anyone else!
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 "<"
795 | '>' -> Buffer.add_string buf ">"
796 | '&' -> Buffer.add_string buf "&"
797 | '"' -> Buffer.add_string buf """
798 | '\'' -> Buffer.add_string buf "'"
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