Splux Linux

source-based Linux using SPS

splux-site · log · files · refs · tags · releases · people · languages · GitHub · commit

0cefdbf


Paginate news and show author, local time, and verified commits.

Date
Commit0cefdbf4bc5eb00d816015e0d8d756c165554461
GitHubmirror

Parents

Stat

 README.md                |   8 +-
 site/assets/live.js      | 245 ++++++++++++++++++++++++++++++++++++++++++-----
 site/assets/style.css    |  68 ++++++++++++-
 site/download/index.html |   2 +-
 site/index.html          |   2 +-
 site/news/index.html     |   7 +-
 tools/build-site.sh      |  34 ++++++-
 tools/collect-news.sh    |  54 ++++++++---
 tools/news.awk           | 165 +++++++++++++++++++++++++++----
 tools/subst.awk          |   6 ++
 10 files changed, 527 insertions(+), 64 deletions(-)

Diff

diff --git a/README.md b/README.md
index d326e8a..e62a4b9 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,13 @@ and refreshed from GitHub when you open the page.
 
 - every published SPS live ISO release (`RobertFlexx/SPS` GitHub Releases)
 - every commit on SPS, sps-core, sps-extra, and this repository that
-  Git or the GitHub API can see
+  Git or the GitHub API can see, with author and whether GitHub verified
+  the signature
+
+The news feed splits into pages of twenty items. Each page is a real
+URL (`/news/`, `/news/2/`, ...). Times in the page HTML are UTC; the
+browser rewrites them to the viewer's local timezone. There is a page
+number field for jumping when the archive grows.
 
 The Pages workflow rebuilds every five minutes, and also on push,
 `workflow_dispatch`, and a `rebuild` repository dispatch. The news and
diff --git a/site/assets/live.js b/site/assets/live.js
index 82cfe4b..df85ab0 100644
--- a/site/assets/live.js
+++ b/site/assets/live.js
@@ -1,8 +1,10 @@
 // Refresh ISO tags and news from GitHub so a delayed Pages build
 // cannot hide a new release or commit. Baked HTML stays if this fails.
+// News times are rewritten to the viewer's local timezone.
 (function () {
 	var API = "https://api.github.com/repos/RobertFlexx/";
 	var ACCEPT = { Accept: "application/vnd.github+json" };
+	var PER = 20;
 	var REPOS = [
 		{ path: "SPS", label: "SPS" },
 		{ path: "sps-core", label: "sps-core" },
@@ -15,6 +17,9 @@
 	var isoEl = document.getElementById("live-iso");
 	var tagEl = document.getElementById("live-tag");
 	var dateEl = document.getElementById("live-date");
+	var thisScript = document.currentScript ||
+		document.querySelector("script[src*=\"live.js\"]");
+	var dataRoot = (thisScript && thisScript.getAttribute("data-root")) || "./";
 	if (!feed && !brief && !isoEl && !tagEl)
 		return;
 
@@ -26,10 +31,31 @@
 			.replace(/"/g, """);
 	}
 
-	function dayOf(iso) {
-		var d = String(iso || "");
-		var t = d.indexOf("T");
-		return t === -1 ? d : d.slice(0, t);
+	function formatLocal(iso) {
+		var d = new Date(iso);
+		if (isNaN(d.getTime()))
+			return iso;
+		try {
+			return d.toLocaleString(undefined, {
+				year: "numeric",
+				month: "short",
+				day: "numeric",
+				hour: "numeric",
+				minute: "2-digit",
+				timeZoneName: "short"
+			});
+		} catch (e) {
+			return d.toString();
+		}
+	}
+
+	function localizeTimes(root) {
+		var times = (root || document).querySelectorAll("time[datetime]");
+		for (var i = 0; i < times.length; i++) {
+			var iso = times[i].getAttribute("datetime");
+			if (iso)
+				times[i].textContent = formatLocal(iso);
+		}
 	}
 
 	function json(url) {
@@ -46,6 +72,32 @@
 		return n === -1 ? msg : msg.slice(0, n);
 	}
 
+	function newsNest() {
+		var p = location.pathname.replace(/\/index\.html$/, "").replace(/\/+$/, "");
+		return /\/news\/\d+$/.test(p);
+	}
+
+	function newsPage() {
+		var p = location.pathname.replace(/\/index\.html$/, "").replace(/\/+$/, "");
+		var m = p.match(/\/news\/(\d+)$/);
+		if (m)
+			return parseInt(m[1], 10) || 1;
+		return 1;
+	}
+
+	function newsHref(n, nest) {
+		if (n < 1)
+			n = 1;
+		if (nest) {
+			if (n <= 1)
+				return "../";
+			return "../" + n + "/";
+		}
+		if (n <= 1)
+			return "./";
+		return n + "/";
+	}
+
 	function readItems(root) {
 		var items = [];
 		if (!root)
@@ -54,23 +106,17 @@
 		for (var i = 0; i < articles.length; i++) {
 			var a = articles[i];
 			var time = a.querySelector("time");
-			var kind = a.querySelector(".kind");
+			var kindEl = a.querySelector(".kind");
 			var link = a.querySelector("h3 a");
 			var summary = a.querySelector(".summary");
-			var meta = a.querySelector(".meta");
 			if (!time || !link)
 				continue;
-			var repo = "";
-			if (meta) {
-				repo = meta.textContent.replace(time.textContent, "");
-				if (kind)
-					repo = repo.replace(kind.textContent, "");
-				repo = repo.replace(/\s+/g, " ").trim();
-			}
 			items.push({
 				iso: time.getAttribute("datetime") || "",
-				kind: kind ? kind.textContent : "commit",
-				repo: repo,
+				kind: a.getAttribute("data-kind") || (kindEl ? kindEl.textContent : "commit"),
+				repo: a.getAttribute("data-repo") || "",
+				author: a.getAttribute("data-author") || "",
+				verified: a.getAttribute("data-verified") === "yes",
 				url: link.getAttribute("href") || "",
 				title: link.textContent || "",
 				summary: summary ? summary.textContent : ""
@@ -79,11 +125,41 @@
 		return items;
 	}
 
+	function fromJson(list) {
+		var items = [];
+		if (!list || !list.length)
+			return items;
+		for (var i = 0; i < list.length; i++) {
+			var it = list[i];
+			if (!it || !it.url)
+				continue;
+			items.push({
+				iso: it.iso || "",
+				kind: it.kind || "commit",
+				repo: it.repo || "",
+				author: it.author || "",
+				verified: !!it.verified,
+				url: it.url,
+				title: it.title || "",
+				summary: it.summary || ""
+			});
+		}
+		return items;
+	}
+
 	function renderItem(it, hideSummary) {
-		var html = "<article class=\"item\">";
+		var html = "<article class=\"item\" data-kind=\"" + esc(it.kind || "") +
+			"\" data-repo=\"" + esc(it.repo || "") +
+			"\" data-author=\"" + esc(it.author || "") +
+			"\" data-verified=\"" + (it.verified ? "yes" : "no") + "\">";
 		html += "<p class=\"meta\"><time datetime=\"" + esc(it.iso) + "\">" +
-			esc(dayOf(it.iso)) + "</time> <span class=\"kind\">" +
-			esc(it.kind) + "</span> " + esc(it.repo) + "</p>";
+			esc(formatLocal(it.iso)) + "</time> <span class=\"kind\">" +
+			esc(it.kind) + "</span> " + esc(it.repo);
+		if (it.author)
+			html += " <span class=\"who\">" + esc(it.author) + "</span>";
+		if (it.verified)
+			html += " <span class=\"verified\">verified</span>";
+		html += "</p>";
 		html += "<h3><a href=\"" + esc(it.url) + "\">" + esc(it.title) + "</a></h3>";
 		if (!hideSummary && it.summary)
 			html += "<p class=\"summary\">" + esc(it.summary) + "</p>";
@@ -128,6 +204,81 @@
 		root.innerHTML = html;
 	}
 
+	function paintPage(root, items, page, per) {
+		if (!root)
+			return;
+		var pages = Math.max(1, Math.ceil(items.length / per) || 1);
+		if (page > pages)
+			page = pages;
+		if (page < 1)
+			page = 1;
+		var start = (page - 1) * per;
+		var slice = items.slice(start, start + per);
+		paint(root, slice, 0, false);
+		return pages;
+	}
+
+	function inWindow(i, page, pages) {
+		var lo = page - 2;
+		var hi = page + 2;
+		if (lo < 1) {
+			hi += (1 - lo);
+			lo = 1;
+		}
+		if (hi > pages) {
+			lo -= (hi - pages);
+			hi = pages;
+		}
+		if (lo < 1)
+			lo = 1;
+		return i === 1 || i === pages || (i >= lo && i <= hi);
+	}
+
+	function paintPager(page, pages, nest, per) {
+		var nav = document.getElementById("news-pager");
+		if (!nav)
+			return;
+		nav.setAttribute("data-page", String(page));
+		nav.setAttribute("data-pages", String(pages));
+		nav.setAttribute("data-per", String(per));
+		if (pages <= 1) {
+			nav.hidden = true;
+			nav.innerHTML = "";
+			return;
+		}
+		nav.hidden = false;
+		var html = "<p class=\"pager-status\">Page " + page + " of " + pages + "</p>";
+		html += "<p class=\"pager-links\">";
+		if (page > 1)
+			html += "<a rel=\"prev\" href=\"" + esc(newsHref(page - 1, nest)) + "\">prev</a> ";
+		var needDots = false;
+		for (var i = 1; i <= pages; i++) {
+			if (!inWindow(i, page, pages)) {
+				needDots = true;
+				continue;
+			}
+			if (needDots) {
+				html += "<span class=\"pager-gap\">...</span> ";
+				needDots = false;
+			}
+			if (i === page)
+				html += "<a href=\"" + esc(newsHref(i, nest)) +
+					"\" aria-current=\"page\">" + i + "</a> ";
+			else
+				html += "<a href=\"" + esc(newsHref(i, nest)) + "\">" + i + "</a> ";
+		}
+		if (page < pages)
+			html += "<a rel=\"next\" href=\"" + esc(newsHref(page + 1, nest)) + "\">next</a>";
+		html += "</p>";
+		html += "<form class=\"pager-jump\" id=\"news-jump\" action=\"#\">";
+		html += "<label for=\"news-page-n\">Go to page</label> ";
+		html += "<input id=\"news-page-n\" name=\"n\" type=\"number\" min=\"1\" max=\"" +
+			pages + "\" value=\"" + page + "\" inputmode=\"numeric\"> ";
+		html += "<button type=\"submit\">Go</button>";
+		html += "</form>";
+		nav.innerHTML = html;
+	}
+
 	function shortSha(sha) {
 		sha = String(sha || "");
 		return sha.length > 7 ? sha.slice(0, 7) : sha;
@@ -146,15 +297,18 @@
 		if (!rel)
 			return;
 		var tag = rel.tag_name || rel.name || "";
-		var when = dayOf(rel.published_at || "");
+		var when = rel.published_at || "";
 		if (isoEl && tag) {
 			isoEl.innerHTML = "<a href=\"https://github.com/RobertFlexx/SPS/releases/latest\">" +
-				esc(tag) + "</a> (" + esc(when) + ")";
+				esc(tag) + "</a> (<time datetime=\"" + esc(when) + "\">" +
+				esc(formatLocal(when)) + "</time>)";
 		}
 		if (tagEl && tag)
 			tagEl.textContent = tag;
-		if (dateEl && when)
-			dateEl.textContent = when;
+		if (dateEl && when) {
+			dateEl.setAttribute("data-iso", when);
+			dateEl.textContent = formatLocal(when);
+		}
 	}
 
 	function fromReleases(list) {
@@ -168,6 +322,8 @@
 				iso: r.published_at || "",
 				kind: "release",
 				repo: "SPS",
+				author: (r.author && r.author.login) || "",
+				verified: false,
 				url: r.html_url || "",
 				title: r.name || r.tag_name || "",
 				summary: body
@@ -185,10 +341,15 @@
 			var commit = c.commit || {};
 			var when = (commit.committer && commit.committer.date) ||
 				(commit.author && commit.author.date) || "";
+			var author = (c.author && c.author.login) ||
+				(commit.author && commit.author.name) || "";
+			var ver = commit.verification || {};
 			items.push({
 				iso: when,
 				kind: "commit",
 				repo: label,
+				author: author,
+				verified: !!ver.verified,
 				url: c.html_url || "",
 				title: firstLine(commit.message),
 				summary: ""
@@ -197,9 +358,30 @@
 		return items;
 	}
 
+	localizeTimes(document);
+
+	document.addEventListener("submit", function (e) {
+		var form = e.target;
+		if (!form || form.id !== "news-jump")
+			return;
+		e.preventDefault();
+		var input = form.querySelector("input[name=\"n\"]") ||
+			document.getElementById("news-page-n");
+		var nav = document.getElementById("news-pager");
+		var pages = nav ? parseInt(nav.getAttribute("data-pages"), 10) || 1 : 1;
+		var n = input ? parseInt(input.value, 10) : 1;
+		if (isNaN(n) || n < 1)
+			n = 1;
+		if (n > pages)
+			n = pages;
+		location.href = newsHref(n, newsNest());
+	});
+
 	var req = [json(API + "SPS/releases?per_page=100")];
 	for (var r = 0; r < REPOS.length; r++)
 		req.push(json(API + REPOS[r].path + "/commits?per_page=100"));
+	if (feed)
+		req.push(json(dataRoot + "data/news.json").catch(function () { return []; }));
 
 	Promise.all(req).then(function (rows) {
 		var releases = rows[0] || [];
@@ -207,9 +389,24 @@
 		for (var i = 0; i < REPOS.length; i++)
 			extra = extra.concat(fromCommits(rows[i + 1] || [], REPOS[i].label));
 
-		var baked = readItems(feed).concat(readItems(brief));
+		var baked = [];
+		if (feed)
+			baked = fromJson(rows[REPOS.length + 1] || []);
+		if (!baked.length)
+			baked = readItems(feed).concat(readItems(brief));
 		var items = merge(baked, extra);
-		paint(feed, items, 0, false);
+
+		if (feed) {
+			var nest = newsNest();
+			var page = newsPage();
+			var pages = Math.max(1, Math.ceil(items.length / PER) || 1);
+			if (page > pages) {
+				location.replace(newsHref(pages, nest));
+				return;
+			}
+			paintPage(feed, items, page, PER);
+			paintPager(page, pages, nest, PER);
+		}
 		paint(brief, items, 8, true);
 
 		var latest = null;
diff --git a/site/assets/style.css b/site/assets/style.css
index ed5878f..6421253 100644
--- a/site/assets/style.css
+++ b/site/assets/style.css
@@ -278,8 +278,74 @@ footer p {
 	font-size: 0.9rem;
 }
 
-.feed .kind {
+.feed .who {
+	color: var(--fg);
+}
+
+.feed .verified {
+	display: inline-block;
+	color: var(--accent);
+	border: 1px solid var(--accent);
+	padding: 0 0.35rem;
+	font-size: 0.8rem;
+}
+
+.pager {
+	margin: 1.25rem 0 0;
+	padding-top: 1rem;
+	border-top: 1px solid var(--rule);
+	color: var(--muted);
+}
+
+.pager-status {
+	margin: 0 0 0.5rem;
+}
+
+.pager-links {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	gap: 0.5rem 0.85rem;
+	margin: 0 0 0.85rem;
+}
+
+.pager-links a[aria-current="page"] {
+	color: var(--fg);
+	text-decoration: underline;
+}
+
+.pager-gap {
+	color: var(--muted);
+}
+
+.pager-jump {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	gap: 0.4rem 0.6rem;
+	margin: 0;
+}
+
+.pager-jump input {
+	width: 4.5rem;
+	background: #111;
+	color: var(--fg);
+	border: 1px solid var(--rule);
+	padding: 0.35rem 0.5rem;
+	font: inherit;
+}
+
+.pager-jump button {
+	font: inherit;
+	background: #111;
 	color: var(--accent);
+	border: 1px solid var(--rule);
+	padding: 0.35rem 0.6rem;
+	cursor: pointer;
+}
+
+.pager-jump button:hover {
+	text-decoration: underline;
 }
 
 .feed .summary {
diff --git a/site/download/index.html b/site/download/index.html
index 6efee0f..326e186 100644
--- a/site/download/index.html
+++ b/site/download/index.html
@@ -37,6 +37,6 @@ Plasma/Wayland live session plus the same installer path. Needs a KMS device (vi
 </main>
 @@FOOTER@@
 </div>
-<script src="../../../../assets/live.js"></script>
+<script src="../../../../assets/live.js" data-root="../../../../"></script>
 </body>
 </html>
diff --git a/site/index.html b/site/index.html
index 979c9d4..59b2d20 100644
--- a/site/index.html
+++ b/site/index.html
@@ -111,6 +111,6 @@ this website
 </main>
 @@FOOTER@@
 </div>
-<script src="../../../../assets/live.js"></script>
+<script src="../../../../assets/live.js" data-root="../../../../"></script>
 </body>
 </html>
diff --git a/site/news/index.html b/site/news/index.html
index ce1f8cc..db885cb 100644
--- a/site/news/index.html
+++ b/site/news/index.html
@@ -3,7 +3,7 @@
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>News - Splux Linux</title>
+<title>News - Splux Linux</title>
 <meta name="description" content="Splux Linux news from ISO releases and git history.">
 <link rel="icon" href="../../../../assets/favicon.png" type="image/png">
 <link rel="stylesheet" href="../../../../assets/style.css">
@@ -17,16 +17,17 @@
 <h2>News</h2>
 <hr class="rule">
 <p>This page is filled from GitHub: every published SPS live ISO release, plus commits on SPS, sps-core, sps-extra, and this website. Nobody has to edit it for a new snapshot or commit to show up.</p>
-<p class="note">The Pages workflow rebuilds every five minutes, on push, and when SPS asks it to. This page also reads GitHub when you open it, so a delayed rebuild cannot hide a new ISO. <a href="../../../../news/atom.xml">Atom feed</a>.</p>
+<p class="note">Times are shown in your local timezone. Commits name the author and whether GitHub verified the signature. Long feeds split into pages of twenty. <a href="../../../../news/atom.xml">Atom feed</a>.</p>
 @@NEWS_INFO@@
 <h2>Feed</h2>
 <hr class="rule">
 <div id="live-feed">
 @@NEWS@@
 </div>
+@@PAGER@@
 </main>
 @@FOOTER@@
 </div>
-<script src="../../../../assets/live.js"></script>
+<script src="../../../../assets/live.js" data-root="../../../../"></script>
 </body>
 </html>
diff --git a/tools/build-site.sh b/tools/build-site.sh
index caae463..4399740 100755
--- a/tools/build-site.sh
+++ b/tools/build-site.sh
@@ -191,14 +191,25 @@ then
 	printf '%s\n' "warning: news collection failed" >&2
 	: >"$tmp/news.tsv"
 fi
-awk -f tools/news.awk -v mode=html "$tmp/news.tsv" >"$tmp/news.html"
 awk -f tools/news.awk -v mode=brief "$tmp/news.tsv" >"$tmp/news-brief.html"
 generated_iso=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u '+%Y-%m-%dT00:00:00Z')
 awk -f tools/news.awk -v mode=atom -v siteurl="$SITE_URL" \
 	-v feedupdated="$generated_iso" \
 	"$tmp/news.tsv" >"$OUT/news/atom.xml"
+awk -f tools/news.awk -v mode=json "$tmp/news.tsv" >"$OUT/data/news.json"
 cp "$tmp/news.tsv" "$OUT/data/news.tsv"
 
+news_per=20
+nnews=$(awk 'END { print NR + 0 }' "$tmp/news.tsv")
+if [ "$nnews" -lt 1 ]; then
+	news_pages=1
+else
+	news_pages=$(( (nnews + news_per - 1) / news_per ))
+fi
+: >"$tmp/pager.html"
+: >"$tmp/news.html"
+pagetitle=
+
 sps_sha=$(short_sha "$SPS")
 core_sha=$(short_sha "$CORE")
 extra_sha=$(short_sha "$EXTRA")
@@ -229,6 +240,8 @@ subst() {
 		-v newsfile="$tmp/news.html" \
 		-v brieffile="$tmp/news-brief.html" \
 		-v infofile="$tmp/news-info.html" \
+		-v pagerfile="$tmp/pager.html" \
+		-v pagetitle="$pagetitle" \
 		-v root="$root" \
 		-v tag="$tag" \
 		-v date="$date" \
@@ -247,7 +260,26 @@ subst site/packages/index.html "$OUT/packages/index.html" "../"
 subst site/install/index.html "$OUT/install/index.html" "../"
 subst site/docs/index.html "$OUT/docs/index.html" "../"
 subst site/source/index.html "$OUT/source/index.html" "../"
+
+awk -f tools/news.awk -v mode=html -v page=1 -v per="$news_per" \
+	"$tmp/news.tsv" >"$tmp/news.html"
+awk -f tools/news.awk -v mode=pager -v page=1 -v pages="$news_pages" \
+	-v per="$news_per" -v nest=0 "$tmp/news.tsv" >"$tmp/pager.html"
+pagetitle=
 subst site/news/index.html "$OUT/news/index.html" "../"
+news_p=2
+while [ "$news_p" -le "$news_pages" ]
+do
+	awk -f tools/news.awk -v mode=html -v page="$news_p" -v per="$news_per" \
+		"$tmp/news.tsv" >"$tmp/news.html"
+	awk -f tools/news.awk -v mode=pager -v page="$news_p" -v pages="$news_pages" \
+		-v per="$news_per" -v nest=1 "$tmp/news.tsv" >"$tmp/pager.html"
+	pagetitle=" page $news_p"
+	subst site/news/index.html "$OUT/news/$news_p/index.html" "../../"
+	news_p=$((news_p + 1))
+done
+pagetitle=
+: >"$tmp/pager.html"
 
 # 404 must use an absolute prefix so nested missing URLs still load CSS.
 if [ -n "$SITE_PREFIX" ]; then
diff --git a/tools/collect-news.sh b/tools/collect-news.sh
index 6038e1c..11ffa3f 100755
--- a/tools/collect-news.sh
+++ b/tools/collect-news.sh
@@ -1,9 +1,10 @@
 #!/bin/sh
 # Collect Splux releases and git history into TSV for the news page.
-# Fields: epoch iso kind repo url title summary
+# Fields: epoch iso kind repo url title summary author verified
 # Env: CORE EXTRA SPS SITE (git work trees). GH_TOKEN optional via gh.
 # Pulls every non-draft SPS release and every commit available from the
-# local clones and from the GitHub API, then drops duplicate URLs.
+# local clones and from the GitHub API. Duplicate URLs keep the last row
+# so GitHub author and verified data win over local git.
 
 set -eu
 
@@ -26,10 +27,18 @@ emit() {
 	url=$5
 	title=$6
 	summary=${7-}
+	author=${8-}
+	verified=${9-}
 	title=$(printf '%s' "$title" | tr '\t\r\n' '   ')
 	summary=$(printf '%s' "$summary" | tr '\t\r\n' '   ')
-	printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
-		"$epoch" "$iso" "$kind" "$repo" "$url" "$title" "$summary" >>"$out"
+	author=$(printf '%s' "$author" | tr '\t\r\n' '   ')
+	case $verified in
+		yes|no) ;;
+		*) verified= ;;
+	esac
+	printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
+		"$epoch" "$iso" "$kind" "$repo" "$url" "$title" "$summary" \
+		"$author" "$verified" >>"$out"
 }
 
 epoch_of() {
@@ -51,27 +60,28 @@ git_commits() {
 	repo=$2
 	baseurl=$3
 	git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
-	git -C "$dir" log --format='%ct%x09%cI%x09%H%x09%s' 2>/dev/null |
-	while IFS=$tab read -r epoch iso hash subject || [ -n "${epoch-}" ]
+	git -C "$dir" log --format='%ct%x09%cI%x09%H%x09%an%x09%s' 2>/dev/null |
+	while IFS=$tab read -r epoch iso hash author subject || [ -n "${epoch-}" ]
 	do
 		[ -n "${epoch-}" ] || continue
 		[ -n "${iso-}" ] || continue
 		[ -n "${hash-}" ] || continue
 		emit "$epoch" "$iso" commit "$repo" \
-			"$baseurl/commit/$hash" "$subject" ""
+			"$baseurl/commit/$hash" "$subject" "" "$author" ""
 	done
 }
 
 read_commit_tsv() {
 	repo=$1
 	file=$2
-	while IFS=$tab read -r published url title || [ -n "${published-}" ]
+	while IFS=$tab read -r published url title author verified || [ -n "${published-}" ]
 	do
 		[ -n "${published-}" ] || continue
 		[ -n "${url-}" ] || continue
 		[ -n "${title-}" ] || title=$url
 		epoch=$(epoch_of "$published")
-		emit "$epoch" "$published" commit "$repo" "$url" "$title" ""
+		emit "$epoch" "$published" commit "$repo" "$url" "$title" "" \
+			"$author" "$verified"
 	done <"$file"
 }
 
@@ -86,15 +96,17 @@ if command -v gh >/dev/null 2>&1; then
 			.published_at,
 			.html_url,
 			((.name // .tag_name) | gsub("[\r\t]"; " ")),
-			((.body // "") | gsub("[\r\t]"; " ") | gsub("\n+"; " ") | .[0:900])
+			((.body // "") | gsub("[\r\t]"; " ") | gsub("\n+"; " ") | .[0:900]),
+			((.author.login // "") | gsub("[\r\t]"; " "))
 		] | @tsv' >"$tmp/rel"
 	then
-		while IFS=$tab read -r published url name body || [ -n "${published-}" ]
+		while IFS=$tab read -r published url name body author || [ -n "${published-}" ]
 		do
 			[ -n "${published-}" ] || continue
 			[ -n "${url-}" ] || continue
 			epoch=$(epoch_of "$published")
-			emit "$epoch" "$published" release SPS "$url" "$name" "$body"
+			emit "$epoch" "$published" release SPS "$url" "$name" "$body" \
+				"$author" ""
 		done <"$tmp/rel"
 	else
 		printf '%s\n' "collect-news: GitHub releases request failed" >&2
@@ -107,7 +119,9 @@ if command -v gh >/dev/null 2>&1; then
 			'.[] | [
 				.commit.committer.date,
 				.html_url,
-				((.commit.message // "") | split("\n")[0] | gsub("[\r\t]"; " "))
+				((.commit.message // "") | split("\n")[0] | gsub("[\r\t]"; " ")),
+				((.author.login // .commit.author.name // "") | gsub("[\r\t]"; " ")),
+				(if .commit.verification.verified == true then "yes" else "no" end)
 			] | @tsv' >"$tmp/c"
 		then
 			read_commit_tsv "$label" "$tmp/c"
@@ -122,5 +136,15 @@ if command -v gh >/dev/null 2>&1; then
 	api_commits RobertFlexx/splux-site splux-site
 fi
 
-LC_ALL=C sort -t "$tab" -k1,1nr "$out" |
-awk -F '\t' 'NF >= 5 && $5 != "" && !seen[$5]++'
+awk -F '\t' '
+NF >= 5 && $5 != "" {
+	row[$5] = $0
+	if (!seen[$5]++) {
+		n++
+		urls[n] = $5
+	}
+}
+END {
+	for (i = 1; i <= n; i++)
+		print row[urls[i]]
+}' "$out" | LC_ALL=C sort -t "$tab" -k1,1nr
diff --git a/tools/news.awk b/tools/news.awk
index cae80f8..af2d0c1 100644
--- a/tools/news.awk
+++ b/tools/news.awk
@@ -1,6 +1,7 @@
-# Turn collect-news.sh TSV into HTML or Atom.
-# Fields: epoch iso kind repo url title summary
-# -v mode=html|brief|atom  -v siteurl=  -v feedupdated=
+# Turn collect-news.sh TSV into HTML, a pager, JSON, or Atom.
+# Fields: epoch iso kind repo url title summary author verified
+# -v mode=html|brief|atom|json|pager
+# -v page= -v per= -v pages= -v nest=0|1  -v siteurl=  -v feedupdated=
 
 BEGIN {
 	FS = "\t"
@@ -8,6 +9,13 @@ BEGIN {
 		mode = "html"
 	if (siteurl == "")
 		siteurl = "https://splux.robertflexx.dev"
+	per = per + 0
+	if (per < 1)
+		per = 20
+	page = page + 0
+	if (page < 1)
+		page = 1
+	nest = nest + 0
 	html_limit = (mode == "brief") ? 8 : 10000
 	atom_limit = 200
 }
@@ -20,10 +28,21 @@ function esc(s) {
 	return s
 }
 
-function day_of(iso,    d) {
-	d = iso
-	sub(/T.*/, "", d)
-	return d
+function jesc(s) {
+	gsub(/\\/, "\\\\", s)
+	gsub(/"/, "\\\"", s)
+	gsub(/\t/, " ", s)
+	return s
+}
+
+function utc_label(iso,    s) {
+	s = iso
+	sub(/T/, " ", s)
+	sub(/:[0-9][0-9](\.[0-9]+)?Z$/, " UTC", s)
+	sub(/Z$/, " UTC", s)
+	if (s == "")
+		return iso
+	return s
 }
 
 function kind_label(k) {
@@ -32,6 +51,17 @@ function kind_label(k) {
 	return "commit"
 }
 
+function page_href(p) {
+	if (nest) {
+		if (p <= 1)
+			return "../"
+		return "../" p "/"
+	}
+	if (p <= 1)
+		return "./"
+	return p "/"
+}
+
 {
 	n++
 	epoch[n] = $1
@@ -41,6 +71,8 @@ function kind_label(k) {
 	url[n] = $5
 	title[n] = $6
 	summary[n] = $7
+	author[n] = $8
+	verified[n] = $9
 }
 
 END {
@@ -48,26 +80,52 @@ END {
 		print_atom()
 		exit
 	}
+	if (mode == "json") {
+		print_json()
+		exit
+	}
+	if (mode == "pager") {
+		print_pager()
+		exit
+	}
 	if (n == 0) {
 		print "<p class=\"note\">No GitHub events were available when this page was built.</p>"
 		exit
 	}
 	print "<div class=\"feed\">"
-	limit = html_limit
-	if (n < limit)
-		limit = n
-	for (i = 1; i <= limit; i++) {
-		print_item(i, mode == "brief")
+	if (mode == "brief") {
+		limit = html_limit
+		if (n < limit)
+			limit = n
+		for (i = 1; i <= limit; i++)
+			print_item(i, 1)
+	} else {
+		start = (page - 1) * per + 1
+		stop = start + per - 1
+		if (start < 1)
+			start = 1
+		if (stop > n)
+			stop = n
+		for (i = start; i <= stop; i++)
+			print_item(i, 0)
 	}
 	print "</div>"
 }
 
-function print_item(i, brief,    k, when, s) {
-	when = esc(day_of(iso[i]))
+function print_item(i, brief,    k, when, s, v, a) {
+	when = esc(utc_label(iso[i]))
 	k = esc(kind_label(kind[i]))
-	print "<article class=\"item\">"
-	printf "<p class=\"meta\"><time datetime=\"%s\">%s</time> <span class=\"kind\">%s</span> %s</p>\n", \
+	a = author[i]
+	v = verified[i]
+	printf "<article class=\"item\" data-kind=\"%s\" data-repo=\"%s\" data-author=\"%s\" data-verified=\"%s\">\n", \
+		k, esc(repo[i]), esc(a), esc(v)
+	printf "<p class=\"meta\"><time datetime=\"%s\">%s</time> <span class=\"kind\">%s</span> %s", \
 		esc(iso[i]), when, k, esc(repo[i])
+	if (a != "")
+		printf " <span class=\"who\">%s</span>", esc(a)
+	if (v == "yes")
+		printf " <span class=\"verified\">verified</span>"
+	print "</p>"
 	printf "<h3><a href=\"%s\">%s</a></h3>\n", esc(url[i]), esc(title[i])
 	if (!brief) {
 		s = summary[i]
@@ -77,7 +135,80 @@ function print_item(i, brief,    k, when, s) {
 	print "</article>"
 }
 
-function print_atom(    i, limit, updated, id) {
+function print_pager(    i, lo, hi, prev, shown, need_dots) {
+	pages = pages + 0
+	if (pages < 1)
+		pages = 1
+	if (page < 1)
+		page = 1
+	if (page > pages)
+		page = pages
+	if (pages <= 1) {
+		printf "<nav class=\"pager\" id=\"news-pager\" hidden data-page=\"1\" data-pages=\"1\" data-per=\"%d\" aria-label=\"News pages\"></nav>\n", per
+		return
+	}
+	lo = page - 2
+	hi = page + 2
+	if (lo < 1) {
+		hi += (1 - lo)
+		lo = 1
+	}
+	if (hi > pages) {
+		lo -= (hi - pages)
+		hi = pages
+	}
+	if (lo < 1)
+		lo = 1
+	printf "<nav class=\"pager\" id=\"news-pager\" data-page=\"%d\" data-pages=\"%d\" data-per=\"%d\" aria-label=\"News pages\">\n", \
+		page, pages, per
+	printf "<p class=\"pager-status\">Page %d of %d</p>\n", page, pages
+	print "<p class=\"pager-links\">"
+	if (page > 1)
+		printf "<a rel=\"prev\" href=\"%s\">prev</a>\n", esc(page_href(page - 1))
+	need_dots = 0
+	for (i = 1; i <= pages; i++) {
+		shown = (i == 1 || i == pages || (i >= lo && i <= hi))
+		if (!shown) {
+			need_dots = 1
+			continue
+		}
+		if (need_dots) {
+			print "<span class=\"pager-gap\">...</span>"
+			need_dots = 0
+		}
+		if (i == page)
+			printf "<a href=\"%s\" aria-current=\"page\">%d</a>\n", \
+				esc(page_href(i)), i
+		else
+			printf "<a href=\"%s\">%d</a>\n", esc(page_href(i)), i
+	}
+	if (page < pages)
+		printf "<a rel=\"next\" href=\"%s\">next</a>\n", esc(page_href(page + 1))
+	print "</p>"
+	print "<form class=\"pager-jump\" id=\"news-jump\" action=\"#\">"
+	printf "<label for=\"news-page-n\">Go to page</label>\n"
+	printf "<input id=\"news-page-n\" name=\"n\" type=\"number\" min=\"1\" max=\"%d\" value=\"%d\" inputmode=\"numeric\">\n", \
+		pages, page
+	print "<button type=\"submit\">Go</button>"
+	print "</form>"
+	print "</nav>"
+}
+
+function print_json(    i, v) {
+	print "["
+	for (i = 1; i <= n; i++) {
+		if (i > 1)
+			print ","
+		v = (verified[i] == "yes") ? "true" : "false"
+		printf "{\"iso\":\"%s\",\"kind\":\"%s\",\"repo\":\"%s\",\"url\":\"%s\",\"title\":\"%s\",\"summary\":\"%s\",\"author\":\"%s\",\"verified\":%s}", \
+			jesc(iso[i]), jesc(kind_label(kind[i])), jesc(repo[i]), \
+			jesc(url[i]), jesc(title[i]), jesc(summary[i]), \
+			jesc(author[i]), v
+	}
+	print "\n]"
+}
+
+function print_atom(    i, limit, updated) {
 	updated = feedupdated
 	if (updated == "" && n > 0)
 		updated = iso[1]
diff --git a/tools/subst.awk b/tools/subst.awk
index 0b49d9c..e1890a0 100644
--- a/tools/subst.awk
+++ b/tools/subst.awk
@@ -58,6 +58,11 @@ $0 == "@@NEWS_INFO@@" {
 	next
 }
 
+$0 == "@@PAGER@@" {
+	dump_file(pagerfile)
+	next
+}
+
 {
 	gsub(/../../../..//, root)
 	gsub(/live-2026-09-06-1/, tag)
@@ -67,5 +72,6 @@ $0 == "@@NEWS_INFO@@" {
 	gsub(/1355/, nextra)
 	gsub(/2026-10-19 13:01 UTC/, generated)
 	gsub(/8.21.0-2/, curlver)
+	gsub(//, pagetitle)
 	print
 }