My agentic slop goes here. Not intended for anyone else!

river

Changed files
+545 -29
stack
river
+354 -22
stack/river/lib/format.ml
···
line-height: 1.5;
color: #333;
background: #fff;
-
max-width: 800px;
margin: 0 auto;
padding: 15px;
}
···
color: #0366d6;
}
.post {
margin-bottom: 25px;
padding-bottom: 20px;
···
}
.author-thumbnail {
-
width: 24px;
-
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.post-meta-text {
flex: 1;
}
.post-excerpt {
···
color: #0366d6;
}
-
.author-list, .category-list {
list-style: none;
}
-
.author-list li, .category-list li {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e1e4e8;
}
-
.author-list li:last-child, .category-list li:last-child {
border-bottom: none;
}
-
.author-list a, .category-list a {
color: #0366d6;
text-decoration: none;
font-size: 15px;
}
-
.author-list a:hover, .category-list a:hover {
text-decoration: underline;
}
···
<style>%s</style>
</head>
<body>
-
<header>
-
<h1><a href="index.html">River Feed</a></h1>
-
<nav>
-
<a href="index.html"%s>Posts</a>
-
<a href="authors/index.html"%s>Authors</a>
-
<a href="categories/index.html"%s>Categories</a>
-
<a href="links.html"%s>Links</a>
-
</nav>
-
</header>
-
<main>
%s
-
</main>
-
<footer>
-
Generated by River Feed Aggregator
-
</footer>
<div class="lightbox" id="lightbox">
<img id="lightbox-img" src="" alt="">
</div>
···
}
}
});
})();
</script>
</body>
···
line-height: 1.5;
color: #333;
background: #fff;
+
display: flex;
+
flex-direction: column;
+
}
+
+
.site-container {
+
display: flex;
+
max-width: 1200px;
margin: 0 auto;
+
width: 100%;
+
}
+
+
.date-tracker {
+
position: sticky;
+
top: 20px;
+
width: 120px;
+
padding: 20px 15px;
+
height: fit-content;
+
flex-shrink: 0;
+
}
+
+
.date-tracker-content {
+
text-align: right;
+
border-right: 3px solid #e1e4e8;
+
padding-right: 15px;
+
}
+
+
.date-year {
+
font-size: 24px;
+
font-weight: 700;
+
color: #0366d6;
+
margin-bottom: 4px;
+
}
+
+
.date-month {
+
font-size: 14px;
+
font-weight: 600;
+
color: #586069;
+
margin-bottom: 2px;
+
}
+
+
.date-day {
+
font-size: 13px;
+
color: #959da5;
+
}
+
+
.main-content {
+
flex: 1;
+
min-width: 0;
padding: 15px;
}
···
color: #0366d6;
}
+
@media (max-width: 768px) {
+
.site-container {
+
flex-direction: column;
+
}
+
+
.date-tracker {
+
position: relative;
+
top: 0;
+
width: 100%;
+
padding: 10px 15px;
+
border-bottom: 1px solid #e1e4e8;
+
}
+
+
.date-tracker-content {
+
text-align: left;
+
border-right: none;
+
border-bottom: 2px solid #e1e4e8;
+
padding-right: 0;
+
padding-bottom: 10px;
+
}
+
+
.main-content {
+
padding: 15px;
+
}
+
}
+
.post {
margin-bottom: 25px;
padding-bottom: 20px;
···
}
.author-thumbnail {
+
width: 48px;
+
height: 48px;
border-radius: 50%;
object-fit: cover;
+
flex-shrink: 0;
}
.post-meta-text {
flex: 1;
+
display: flex;
+
flex-direction: column;
+
justify-content: center;
+
}
+
+
.author-name {
+
font-size: 14px;
+
font-weight: 600;
+
color: #24292e;
+
margin-bottom: 2px;
+
}
+
+
.post-date {
+
font-size: 12px;
+
color: #586069;
}
.post-excerpt {
···
color: #0366d6;
}
+
.author-list {
list-style: none;
}
+
.author-item {
+
display: flex;
+
align-items: center;
+
gap: 12px;
+
padding: 12px 0;
+
border-bottom: 1px solid #e1e4e8;
+
transition: background 0.15s;
+
}
+
+
.author-item:hover {
+
background: #f6f8fa;
+
margin: 0 -8px;
+
padding: 12px 8px;
+
}
+
+
.author-item-thumbnail {
+
width: 40px;
+
height: 40px;
+
border-radius: 50%;
+
object-fit: cover;
+
flex-shrink: 0;
+
border: 1px solid #e1e4e8;
+
}
+
+
.author-item-main {
+
flex: 1;
+
min-width: 0;
+
}
+
+
.author-item-name {
+
font-size: 15px;
+
font-weight: 600;
+
margin-bottom: 2px;
+
}
+
+
.author-item-name a {
+
color: #24292e;
+
text-decoration: none;
+
}
+
+
.author-item-name a:hover {
+
color: #0366d6;
+
}
+
+
.author-item-meta {
+
display: flex;
+
align-items: center;
+
gap: 12px;
+
font-size: 13px;
+
color: #586069;
+
flex-wrap: wrap;
+
}
+
+
.author-item-username {
+
color: #586069;
+
}
+
+
.author-item-stat {
+
display: inline-flex;
+
align-items: center;
+
gap: 4px;
+
color: #586069;
+
}
+
+
.author-item-links {
+
display: flex;
+
align-items: center;
+
gap: 6px;
+
}
+
+
.author-item-link {
+
display: inline-flex;
+
align-items: center;
+
color: #586069;
+
text-decoration: none;
+
transition: color 0.2s;
+
}
+
+
.author-item-link:hover {
+
color: #0366d6;
+
}
+
+
.author-item-link svg {
+
width: 16px;
+
height: 16px;
+
}
+
+
.author-header {
+
background: #f6f8fa;
+
border: 1px solid #e1e4e8;
+
border-radius: 6px;
+
padding: 24px;
+
margin-bottom: 24px;
+
}
+
+
.author-header-main {
+
display: flex;
+
align-items: start;
+
gap: 20px;
+
margin-bottom: 20px;
+
}
+
+
.author-header-thumbnail {
+
width: 96px;
+
height: 96px;
+
border-radius: 50%;
+
object-fit: cover;
+
border: 3px solid #fff;
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+
flex-shrink: 0;
+
}
+
+
.author-header-info {
+
flex: 1;
+
}
+
+
.author-header-name {
+
font-size: 28px;
+
font-weight: 700;
+
color: #24292e;
+
margin-bottom: 6px;
+
}
+
+
.author-header-username {
+
font-size: 16px;
+
color: #586069;
+
margin-bottom: 12px;
+
}
+
+
.author-header-bio {
+
font-size: 14px;
+
color: #586069;
+
line-height: 1.5;
+
margin-bottom: 12px;
+
}
+
+
.author-header-links {
+
display: flex;
+
flex-wrap: wrap;
+
gap: 10px;
+
}
+
+
.author-header-link {
+
display: inline-flex;
+
align-items: center;
+
padding: 6px 12px;
+
background: #fff;
+
border: 1px solid #e1e4e8;
+
border-radius: 4px;
+
font-size: 13px;
+
color: #586069;
+
text-decoration: none;
+
transition: all 0.2s;
+
}
+
+
.author-header-link:hover {
+
border-color: #0366d6;
+
color: #0366d6;
+
}
+
+
.author-header-stats {
+
display: flex;
+
gap: 24px;
+
padding-top: 16px;
+
border-top: 1px solid #e1e4e8;
+
}
+
+
.author-header-stat {
+
display: flex;
+
flex-direction: column;
+
}
+
+
.author-header-stat-value {
+
font-size: 24px;
+
font-weight: 700;
+
color: #0366d6;
+
}
+
+
.author-header-stat-label {
+
font-size: 12px;
+
color: #586069;
+
text-transform: uppercase;
+
letter-spacing: 0.5px;
+
}
+
+
.category-list {
+
list-style: none;
+
}
+
+
.category-list li {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e1e4e8;
}
+
.category-list li:last-child {
border-bottom: none;
}
+
.category-list a {
color: #0366d6;
text-decoration: none;
font-size: 15px;
}
+
.category-list a:hover {
text-decoration: underline;
}
···
<style>%s</style>
</head>
<body>
+
<div class="site-container">
+
<aside class="date-tracker" id="date-tracker">
+
<div class="date-tracker-content">
+
<div class="date-year" id="current-year">2025</div>
+
<div class="date-month" id="current-month">January</div>
+
<div class="date-day" id="current-day">1</div>
+
</div>
+
</aside>
+
<div class="main-content">
+
<header>
+
<h1><a href="index.html">River Feed</a></h1>
+
<nav>
+
<a href="index.html"%s>Posts</a>
+
<a href="authors/index.html"%s>Authors</a>
+
<a href="categories/index.html"%s>Categories</a>
+
<a href="links.html"%s>Links</a>
+
</nav>
+
</header>
+
<main>
%s
+
</main>
+
<footer>
+
Generated by River Feed Aggregator
+
</footer>
+
</div>
+
</div>
<div class="lightbox" id="lightbox">
<img id="lightbox-img" src="" alt="">
</div>
···
}
}
});
+
+
// Date tracker scroll update
+
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
+
'July', 'August', 'September', 'October', 'November', 'December'];
+
+
function updateDateTracker() {
+
const posts = document.querySelectorAll('.post[data-date]');
+
if (posts.length === 0) return;
+
+
// Find the post currently in the middle of the viewport
+
const viewportMiddle = window.scrollY + (window.innerHeight / 2);
+
let closestPost = posts[0];
+
let closestDistance = Math.abs(posts[0].offsetTop - viewportMiddle);
+
+
posts.forEach(post => {
+
const distance = Math.abs(post.offsetTop - viewportMiddle);
+
if (distance < closestDistance) {
+
closestDistance = distance;
+
closestPost = post;
+
}
+
});
+
+
const dateStr = closestPost.getAttribute('data-date');
+
if (dateStr) {
+
const date = new Date(dateStr);
+
const yearEl = document.getElementById('current-year');
+
const monthEl = document.getElementById('current-month');
+
const dayEl = document.getElementById('current-day');
+
+
if (yearEl) yearEl.textContent = date.getFullYear();
+
if (monthEl) monthEl.textContent = monthNames[date.getMonth()];
+
if (dayEl) dayEl.textContent = date.getDate();
+
}
+
}
+
+
// Update on scroll and initial load
+
let scrollTimeout;
+
window.addEventListener('scroll', function() {
+
clearTimeout(scrollTimeout);
+
scrollTimeout = setTimeout(updateDateTracker, 50);
+
});
+
+
// Initial update
+
updateDateTracker();
})();
</script>
</body>
+191 -7
stack/river/lib/state.ml
···
(* We'll need to adapt this since we're working with Atom entries *)
let post_html =
let date_str = Format.Html.format_date date in
let link_html = match link with
| Some uri ->
Printf.sprintf {|<a href="%s">%s</a>|}
···
(Format.Html.html_escape author)
| None -> ""
in
-
Printf.sprintf {|<article class="post">
<h2 class="post-title">%s</h2>
<div class="post-meta">
-
%s<div class="post-meta-text">By <a href="authors/%s.html">%s</a> on %s</div>
</div>
<div class="post-excerpt">
%s
···
<a href="#" class="read-more">Read more</a>
%s
</article>|}
link_html
thumbnail_html
(Format.Html.html_escape (sanitize_filename username))
···
Log.info (fun m -> m "Found %d authors" (List.length authors_list));
let authors_index_content =
-
let items = List.map (fun (username, author, count) ->
-
Printf.sprintf {|<li><a href="%s.html">%s</a><span class="count">%d post%s</span></li>|}
(Format.Html.html_escape (sanitize_filename username))
-
(Format.Html.html_escape author)
count
(if count = 1 then "" else "s")
) authors_list in
-
Printf.sprintf "<ul class=\"author-list\">\n%s\n</ul>"
(String.concat "\n" items)
in
···
let author_pages = (author_total + posts_per_page - 1) / posts_per_page in
Log.info (fun m -> m " Author: %s (@%s) - %d posts, %d pages" author username author_total author_pages);
for page = 1 to author_pages do
let start_idx = (page - 1) * posts_per_page in
let page_posts = List.filteri (fun i _ ->
···
tags_html
) page_posts in
let page_html = Format.Html.render_posts_page
~title:(author ^ " - " ^ title)
-
~posts:post_htmls
~current_page:page
~total_pages:author_pages
~base_path:(sanitize_filename username ^ "-")
···
(* We'll need to adapt this since we're working with Atom entries *)
let post_html =
let date_str = Format.Html.format_date date in
+
(* Format date for data attribute (ISO 8601) *)
+
let date_iso = Ptime.to_rfc3339 date in
let link_html = match link with
| Some uri ->
Printf.sprintf {|<a href="%s">%s</a>|}
···
(Format.Html.html_escape author)
| None -> ""
in
+
Printf.sprintf {|<article class="post" data-date="%s">
<h2 class="post-title">%s</h2>
<div class="post-meta">
+
%s<div class="post-meta-text">
+
<div class="author-name"><a href="authors/%s.html">%s</a></div>
+
<div class="post-date">%s</div>
+
</div>
</div>
<div class="post-excerpt">
%s
···
<a href="#" class="read-more">Read more</a>
%s
</article>|}
+
date_iso
link_html
thumbnail_html
(Format.Html.html_escape (sanitize_filename username))
···
Log.info (fun m -> m "Found %d authors" (List.length authors_list));
let authors_index_content =
+
(* SVG icon definitions *)
+
let icon_github = {|<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>|} in
+
let icon_email = {|<svg viewBox="0 0 16 16" fill="currentColor"><path d="M0 4a2 2 0 012-2h12a2 2 0 012 2v8a2 2 0 01-2 2H2a2 2 0 01-2-2V4zm2-1a1 1 0 00-1 1v.217l7 4.2 7-4.2V4a1 1 0 00-1-1H2zm13 2.383l-4.758 2.855L15 11.114v-5.73zm-.034 6.878L9.271 8.82 8 9.583 6.728 8.82l-5.694 3.44A1 1 0 002 13h12a1 1 0 00.966-.739zM1 11.114l4.758-2.876L1 5.383v5.73z"/></svg>|} in
+
let icon_link = {|<svg viewBox="0 0 16 16" fill="currentColor"><path d="M4.715 6.542L3.343 7.914a3 3 0 104.243 4.243l1.828-1.829A3 3 0 008.586 5.5L8 6.086a1.001 1.001 0 00-.154.199 2 2 0 01.861 3.337L6.88 11.45a2 2 0 11-2.83-2.83l.793-.792a4.018 4.018 0 01-.128-1.287z"/><path d="M6.586 4.672A3 3 0 007.414 9.5l.775-.776a2 2 0 01-.896-3.346L9.12 3.55a2 2 0 112.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 10-4.243-4.243L6.586 4.672z"/></svg>|} in
+
let icon_rss = {|<svg viewBox="0 0 16 16" fill="currentColor"><path d="M2 0a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V2a2 2 0 00-2-2H2zm1.5 2.5c5.523 0 10 4.477 10 10a1 1 0 11-2 0 8 8 0 00-8-8 1 1 0 010-2zm0 4a6 6 0 016 6 1 1 0 11-2 0 4 4 0 00-4-4 1 1 0 010-2zm.5 7a1.5 1.5 0 110-3 1.5 1.5 0 010 3z"/></svg>|} in
+
+
let items = List.map (fun (username, _author, count) ->
+
(* Get Sortal contact data *)
+
let contact_opt = Sortal.lookup state.sortal username in
+
+
(* Get the proper display name from Sortal, fallback to username *)
+
let display_name = match contact_opt with
+
| Some contact -> Sortal.Contact.name contact
+
| None -> username
+
in
+
+
let thumbnail_html = match contact_opt with
+
| Some _contact ->
+
(match get_author_thumbnail username with
+
| Some thumb_path ->
+
Printf.sprintf {|<img src="../%s" alt="%s" class="author-item-thumbnail">|}
+
(Format.Html.html_escape thumb_path)
+
(Format.Html.html_escape display_name)
+
| None ->
+
Printf.sprintf {|<div class="author-item-thumbnail" style="background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 700;">%s</div>|}
+
(String.uppercase_ascii (String.sub display_name 0 1)))
+
| None ->
+
Printf.sprintf {|<div class="author-item-thumbnail" style="background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 700;">%s</div>|}
+
(String.uppercase_ascii (String.sub display_name 0 1))
+
in
+
+
let links_html = match contact_opt with
+
| Some contact ->
+
let links = [] in
+
let links = match Sortal.Contact.github contact with
+
| Some gh -> (Printf.sprintf {|<a href="https://github.com/%s" class="author-item-link" target="_blank" title="GitHub">%s</a>|} gh icon_github) :: links
+
| None -> links
+
in
+
let links = match Sortal.Contact.url contact with
+
| Some url -> (Printf.sprintf {|<a href="%s" class="author-item-link" target="_blank" title="Website">%s</a>|} url icon_link) :: links
+
| None -> links
+
in
+
let links = match Sortal.Contact.email contact with
+
| Some email -> (Printf.sprintf {|<a href="mailto:%s" class="author-item-link" title="Email">%s</a>|} email icon_email) :: links
+
| None -> links
+
in
+
if links = [] then "" else
+
Printf.sprintf {|<div class="author-item-links">%s</div>|} (String.concat "" (List.rev links))
+
| None -> ""
+
in
+
+
let feed_count = match contact_opt with
+
| Some contact ->
+
(match Sortal.Contact.feeds contact with
+
| Some feeds -> List.length feeds
+
| None -> 0)
+
| None -> 0
+
in
+
+
Printf.sprintf {|<div class="author-item">
+
%s
+
<div class="author-item-main">
+
<div class="author-item-name"><a href="%s.html">%s</a></div>
+
<div class="author-item-meta">
+
<span class="author-item-username">@%s</span>
+
<span class="author-item-stat">%d post%s</span>
+
%s
+
%s
+
</div>
+
</div>
+
</div>|}
+
thumbnail_html
(Format.Html.html_escape (sanitize_filename username))
+
(Format.Html.html_escape display_name)
+
(Format.Html.html_escape username)
count
(if count = 1 then "" else "s")
+
(if feed_count > 0 then Printf.sprintf {|<span class="author-item-stat">%s %d feed%s</span>|} icon_rss feed_count (if feed_count = 1 then "" else "s") else "")
+
links_html
) authors_list in
+
Printf.sprintf "<div class=\"author-list\">\n%s\n</div>"
(String.concat "\n" items)
in
···
let author_pages = (author_total + posts_per_page - 1) / posts_per_page in
Log.info (fun m -> m " Author: %s (@%s) - %d posts, %d pages" author username author_total author_pages);
+
(* Generate author header with Sortal data *)
+
let author_header =
+
let contact_opt = Sortal.lookup state.sortal username in
+
+
(* Get proper display name from Sortal *)
+
let display_name = match contact_opt with
+
| Some contact -> Sortal.Contact.name contact
+
| None -> author
+
in
+
+
(* SVG icons for author header *)
+
let icon_github = {|<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>|} in
+
let icon_email = {|<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M0 4a2 2 0 012-2h12a2 2 0 012 2v8a2 2 0 01-2 2H2a2 2 0 01-2-2V4zm2-1a1 1 0 00-1 1v.217l7 4.2 7-4.2V4a1 1 0 00-1-1H2zm13 2.383l-4.758 2.855L15 11.114v-5.73zm-.034 6.878L9.271 8.82 8 9.583 6.728 8.82l-5.694 3.44A1 1 0 002 13h12a1 1 0 00.966-.739zM1 11.114l4.758-2.876L1 5.383v5.73z"/></svg>|} in
+
let icon_link = {|<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4.715 6.542L3.343 7.914a3 3 0 104.243 4.243l1.828-1.829A3 3 0 008.586 5.5L8 6.086a1.001 1.001 0 00-.154.199 2 2 0 01.861 3.337L6.88 11.45a2 2 0 11-2.83-2.83l.793-.792a4.018 4.018 0 01-.128-1.287z"/><path d="M6.586 4.672A3 3 0 007.414 9.5l.775-.776a2 2 0 01-.896-3.346L9.12 3.55a2 2 0 112.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 10-4.243-4.243L6.586 4.672z"/></svg>|} in
+
+
match contact_opt with
+
| Some contact ->
+
let thumbnail_html = match get_author_thumbnail username with
+
| Some thumb_path ->
+
Printf.sprintf {|<img src="../%s" alt="%s" class="author-header-thumbnail">|}
+
(Format.Html.html_escape thumb_path)
+
(Format.Html.html_escape display_name)
+
| None ->
+
Printf.sprintf {|<div class="author-header-thumbnail" style="background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 700;">%s</div>|}
+
(String.uppercase_ascii (String.sub display_name 0 1))
+
in
+
+
let links = [] in
+
let links = match Sortal.Contact.github contact with
+
| Some gh -> (Printf.sprintf {|<a href="https://github.com/%s" class="author-header-link" target="_blank">%s GitHub</a>|} gh icon_github) :: links
+
| None -> links
+
in
+
let links = match Sortal.Contact.twitter contact with
+
| Some tw -> (Printf.sprintf {|<a href="https://twitter.com/%s" class="author-header-link" target="_blank">%s Twitter</a>|} tw icon_link) :: links
+
| None -> links
+
in
+
let links = match Sortal.Contact.mastodon contact with
+
| Some m -> (Printf.sprintf {|<a href="%s" class="author-header-link" target="_blank">%s Mastodon</a>|} m icon_link) :: links
+
| None -> links
+
in
+
let links = match Sortal.Contact.url contact with
+
| Some url -> (Printf.sprintf {|<a href="%s" class="author-header-link" target="_blank">%s Website</a>|} url icon_link) :: links
+
| None -> links
+
in
+
let links = match Sortal.Contact.email contact with
+
| Some email -> (Printf.sprintf {|<a href="mailto:%s" class="author-header-link">%s Email</a>|} email icon_email) :: links
+
| None -> links
+
in
+
+
let links_html = if links = [] then "" else
+
Printf.sprintf {|<div class="author-header-links">%s</div>|} (String.concat "" (List.rev links))
+
in
+
+
let feed_count = match Sortal.Contact.feeds contact with
+
| Some feeds -> List.length feeds
+
| None -> 0
+
in
+
+
Printf.sprintf {|<div class="author-header">
+
<div class="author-header-main">
+
%s
+
<div class="author-header-info">
+
<div class="author-header-name">%s</div>
+
<div class="author-header-username">@%s</div>
+
%s
+
</div>
+
</div>
+
<div class="author-header-stats">
+
<div class="author-header-stat">
+
<div class="author-header-stat-value">%d</div>
+
<div class="author-header-stat-label">Posts</div>
+
</div>
+
<div class="author-header-stat">
+
<div class="author-header-stat-value">%d</div>
+
<div class="author-header-stat-label">Feeds</div>
+
</div>
+
</div>
+
</div>|}
+
thumbnail_html
+
(Format.Html.html_escape display_name)
+
(Format.Html.html_escape username)
+
links_html
+
author_total
+
feed_count
+
| None ->
+
Printf.sprintf {|<div class="author-header">
+
<div class="author-header-main">
+
<div class="author-header-info">
+
<div class="author-header-name">%s</div>
+
<div class="author-header-username">@%s</div>
+
</div>
+
</div>
+
<div class="author-header-stats">
+
<div class="author-header-stat">
+
<div class="author-header-stat-value">%d</div>
+
<div class="author-header-stat-label">Posts</div>
+
</div>
+
</div>
+
</div>|}
+
(Format.Html.html_escape display_name)
+
(Format.Html.html_escape username)
+
author_total
+
in
+
for page = 1 to author_pages do
let start_idx = (page - 1) * posts_per_page in
let page_posts = List.filteri (fun i _ ->
···
tags_html
) page_posts in
+
let posts_with_header = author_header ^ "\n" ^ String.concat "\n" post_htmls in
let page_html = Format.Html.render_posts_page
~title:(author ^ " - " ^ title)
+
~posts:[posts_with_header]
~current_page:page
~total_pages:author_pages
~base_path:(sanitize_filename username ^ "-")