clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

A home page of profiles, what happened lately, and topics

cat committed a92a269
The home page was the shelves of every repo, which each owner's page has
too. Now it says who is here and what they have been doing, in two
columns (one on phones: profiles, recent, topics):

- a quiet line under the header, .forge/motd ("a personal forge on plain
  git" without the file, nothing when it is empty);
- profiles: each owner with public repos, a letter on a colour seeded
  from the name (the accent for FORGE_OWNER, new --avatar-* tokens the
  same in both modes), how many repos and when they were last active;
- recent: the last eight landed reviews, pushes to a default branch,
  releases and site deploys of public repos, newest first, each linking
  to what it names, with "insights" at the end of its head strip;
- topics: the shelves across every public repo, in tiles. One owner's
  links to that shelf on their page (/cat/#tools, which ui.ts now opens on
  its tab); one several owners share to /topics/#tools, a new page of
  every public repo by shelf, each naming its owner.

lib/recent.ts makes the list. Landed reviews come from the review logs;
pushes, releases and site deploys from the events log, whose push lines
now carry the default branch's new tip as head, so a push can be named
by its subject, and a push whose head is a review's tip shows once, as
that review landing. Every build redoes the home page and live.ts swaps
it in, so it is as fresh as the last push. Private repos are never read,
and owners with only private repos have no profile.
commit
a92a26928f539747da142a4a61470cc01ea3182a
parent
c22212941adb076fda008e3cf7e8ddd1ea4e6978
author
cat <admin@dev.site>
ci
✓ passed in 88s, signed by cat
kind
feat risk score 2.6 of 3, Jev's estimate
review
#21 A home page of profiles, recent and topics; motd for admins; /login/ in panels
ci attestation
forge-attestation 1
type ci
commit a92a26928f539747da142a4a61470cc01ea3182a
tree e58d32f3c68703d0e4d365e1ae0c47547f6529c3
script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7
result pass
exit 0
started 2026-10-19T04:42:23Z
duration 88s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:046de8c13a925c3d2c1f7fa8c3e17bcbb4eac63a899a5aef9706c0016d550b59 279 lines
|pushed wip/i18n
|c5e91ec running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|c5e91ec pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|c194ebb running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|c194ebb fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|5008b8b running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|5008b8b pass in 0s, signed
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|warning: You appear to have cloned an empty repository.
|site home: deployed d4f0dd4 -> /sites/ana/dotfiles/home/
|created ana/notes
|remote: review #1: /ana/notes/reviews/1/
|pushed ana/todo
|landing #1 A todo list: ana/todo -> main
|f45c858 running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|f45c858 pass in 0s, signed
|pushed main
|e1ebc74 running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|e1ebc74 pass in 0s, signed
|remote: review #7: /dami/greet/reviews/7/
|pushed dami/regions
|created dami/forge
|warning: You appear to have cloned an empty repository.
|site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/
|site guide: deployed abeeb6b -> /sites/dami/forge/guide/
|(node:80360) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/dotfiles in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.AqAagy/wt/demo/root/.forge/www
|(node:80523) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/notes in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.AqAagy/wt/demo/root/.forge/www
|(node:80698) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.AqAagy/wt/demo/root/.forge/www
|(node:80858) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.AqAagy/wt/demo/root/.forge/www
|(node:81556) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built - in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.AqAagy/wt/demo/root/.forge/www
|235 of 235 pages render the same without 11ty
|guide: 8 files, links ok
|ok

Signature: good, cat

20 files changed, +567 -27
MDESIGN.md6+-
MREADME.md22++++-
Mbin/forge15++-
Mlib/forge.ts47++++++++-
Alib/motd.ts49++++++++
Alib/recent.ts175++++++++++++++++++++++++++++++
Mlib/render-page.ts5+-
Mlib/reviews.ts5+-
Msite/_includes/macros.njk2+-
Msite/assets/style.css7+
Msite/assets/ui.ts6+-
Msite/index.njk95++++++++++++++++-
Asite/topics.njk16+++
Atest/scenarios/home-profiles.sh35++++++
Atest/scenarios/home-recent.sh58++++++++++
Atest/scenarios/home-topics.sh37++++++
Mtest/scenarios/insights-pages.sh2+-
Mtest/scenarios/panels-shelves.sh4+-
Mtest/scenarios/shelves-site-toggle.sh4+-
Mtest/scenarios/shelves.sh4+-
M DESIGN.md +4 -2
M README.md +21 -1
@@ -1326,7 +1326,27 @@ first build takes minutes.
13261326 13271327 ## Reading pages13281328 1329-The index and each owner's page show repos as rows on shelves by category,1329+The home page has a quiet line under its header, `.forge/motd` (no file1330+is "a personal forge on plain git", an empty one no line), then three panels. **profiles**: each1331+owner with public repos, how many and when they were last active.1332+**recent**: the last eight things that happened on public repos, newest1333+first: landed reviews, pushes to a default branch, releases (a tag or1334+release pushed) and site deploys, each linking to what it names1335+(`lib/recent.ts`). Landed reviews come from the review logs; the rest1336+from `.forge/events.jsonl`, whose push lines carry the default branch's new1337+tip as `head`. A push whose head is a review's tip is that review landing,1338+and shows once, as landed. Every build redoes the home page, and open1339+copies of it swap in the new one when the build is done, so the list is1340+as fresh as the last push; its times stay relative in the browser. The1341+events log is cut and left out of backups, so after a restore the list has1342+only landed reviews until the next pushes. **topics**: the shelves (below)1343+across every public repo; one owner's links to that shelf on their page1344+(`/dami/#games`, which opens on its tab), and one that several owners1345+share to `/topics/#games`, every public repo on its shelf, each naming its1346+owner. Private repos, and owners with only private ones, are never on1347+any of it.1348+1349+Each owner's page, and `/topics/`, show repos as rows on shelves by category,13301350 newest first. A repo's category is one line in the bare repo's `category`13311351 file, next to `description` (`printf 'games\n' > /srv/git/dami/greet.git/category`).13321352 A repo without one goes on the shelf for what Jev says it is (a web app, a
M bin/forge +9 -6
@@ -1020,7 +1020,7 @@ load_config() {
10201020 10211021 valid_name() {10221022 case $1 in1023- '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | git-shell-commands) return 1 ;;1023+ '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | topics | git-shell-commands) return 1 ;;10241024 esac10251025 }10261026
@@ -2636,13 +2636,13 @@ noreview
26362636 DEFAULT=$first26372637 fi26382638 fi2639- PUSHED= rel=02639+ PUSHED= rel=0 head=26402640 while read -r old new ref; do26412641 case $ref in refs/heads/* | refs/tags/* | refs/sites/* | refs/releases/*) PUSHED="$PUSHED $ref" ;; esac26422642 case $ref in refs/tags/* | refs/releases/*) rel=1 ;; esac26432643 [ "$new" != "$ZERO" ] || case $ref in refs/heads/*) drop_previews "${ref#refs/heads/}" ;; esac26442644 case $ref in2645- "refs/heads/$DEFAULT") ;;2645+ "refs/heads/$DEFAULT") [ "$new" = "$ZERO" ] || head=$new ;;26462646 refs/heads/*) [ "$noreview" = 1 ] || record_review "${ref#refs/heads/}" "$new" ;;26472647 refs/sites/*) push_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$new" ;;26482648 refs/site-previews/*)
@@ -2658,7 +2658,10 @@ noreview
26582658 if [ "$rel" = 1 ]; then release_latest "$(pages_root "$GIT_DIR")"; fi26592659 git update-server-info26602660 refs=$(printf '%s' "$PUSHED" | tr -d '"\\')2661- printf '{"t":%s000,"repo":"%s","type":"push","user":"%s","refs":"%s"%s}\n' "${FORGE_NOW:-$(date +%s)}" "$OWNER/$NAME" "${USER_:-admin}" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"2661+ # head: the default branch's new tip, for the index's recent list (lib/recent.ts).2662+ hf=2663+ [ -z "$head" ] || hf=",\"head\":\"$head\""2664+ printf '{"t":%s000,"repo":"%s","type":"push","user":"%s"%s,"refs":"%s"%s}\n' "${FORGE_NOW:-$(date +%s)}" "$OWNER/$NAME" "${USER_:-admin}" "$hf" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"26622665 rebuild "$OWNER/$NAME" push26632666 }26642667
@@ -2695,7 +2698,7 @@ build_all() {
26952698 [ -d "$w" ] || continue26962699 slug=${w#"$FORGE_WWW"/}26972700 slug=${slug%/}2698- case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/*) continue ;; esac2701+ case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/* | topics/*) continue ;; esac26992702 [ -f "$FORGE_ROOT/$slug.git/HEAD" ] && [ -f "$FORGE_ROOT/$slug.git/git-daemon-export-ok" ] || rm -rf "$w"27002703 done27012704 for w in "$FORGE_WWW"/sites/*/*/ "$FORGE_WWW"/previews/*/*/; do
@@ -2706,7 +2709,7 @@ build_all() {
27062709 done27072710 for w in "$FORGE_WWW"/*/; do27082711 b=$(basename "$w")2709- case $b in assets | sites | previews | hosts | insights | design) continue ;; esac2712+ case $b in assets | sites | previews | hosts | insights | design | topics) continue ;; esac27102713 [ -d "$FORGE_ROOT/$b" ] || rm -rf "$w"27112714 done27122715 if [ -d "$FORGE_WWW/hosts" ]; then node "$FORGE_HOME/lib/hosts.ts" prune; fi
M lib/forge.ts +44 -3
@@ -22,6 +22,8 @@ import { labelCommits, labelKey, releases, repoTopic } from './jev-commits.ts';
2222 import { annotateReviews, answersStamp, isMechanical, pairChanges } from './jev-reviews.ts';2323 import { HighlightCache } from './highlight.ts';2424 import { assets } from './assets.ts';25+import { recent, type RecentItem } from './recent.ts';26+import { motd } from './motd.ts';2527 import type { CiStatus, Commit, Config, DiffFile, Repo, RepoCounts, RepoSummary, RepoUrls, Review, ReviewComment, SiteLink } from './types.ts';2628 2729 export function config(env: NodeJS.ProcessEnv = process.env): Config {
@@ -52,7 +54,7 @@ export function config(env: NodeJS.ProcessEnv = process.env): Config {
5254 const caseless = (a: string, b: string): number => a.localeCompare(b, 'en', { sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0);5355 5456 // Top-level names used by the forge itself; never owners.55-const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'insights', 'index.html', 'git-shell-commands', 'lost+found', 'login', 'private']);57+const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'insights', 'topics', 'index.html', 'git-shell-commands', 'lost+found', 'login', 'private']);5658 5759 // Every repo, public or not (RepoSummary.private); builds filter.5860 export function discover(cfg: Config): { repos: RepoSummary[]; owners: string[] } {
@@ -117,6 +119,12 @@ export interface Forge {
117119 config: Config;118120 repos: RepoSummary[];119121 owners: { owner: string; repos: RepoSummary[] }[];122+ // The index (site/index.njk): its line under the header (null: none),123+ // the public owners, the shelves across them, and what happened lately.124+ motd: string | null;125+ profiles: Profile[];126+ topics: Topic[];127+ recent: RecentItem[];120128 pages: Record<string, Page[]>;121129 raw: RawFile[];122130 // Incremental builds: the live site, and the pages taken from it unchanged.
@@ -126,6 +134,32 @@ export interface Forge {
126134 pageCaches: { cache: RepoCache; fps: Record<string, string> }[];127135 }128136 137+export interface Profile {138+ owner: string;139+ repos: number;140+ // When they last did something public (lib/recent.ts), ISO.141+ active: string;142+ // Their letter avatar's colour: avatar-1 to avatar-5, seeded from the143+ // name, or the accent for the forge's own owner (FORGE_OWNER).144+ colour: string;145+}146+147+export interface Topic {148+ id: string;149+ title: string;150+ repos: number;151+ // One owner's: that shelf on their page; several owners': on /topics/.152+ href: string;153+}154+155+const AVATARS = 5;156+function colour(owner: string, env: NodeJS.ProcessEnv): string {157+ if (owner === env.FORGE_OWNER) return 'accent';158+ let h = 0;159+ for (const ch of owner) h = (h * 31 + ch.codePointAt(0)!) >>> 0;160+ return `avatar-${(h % AVATARS) + 1}`;161+}162+129163 let last: Forge | null = null;130164 export const current = (): Forge | null => last;131165
@@ -385,10 +419,17 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
385419 fs.writeFileSync(summaryCache, JSON.stringify(Object.fromEntries(summaries.map((s) => [s.slug, cached[s.slug] ?? { counts: {}, sites: [] }]))));386420 387421 const owners = ownerNames.map((owner) => ({ owner, repos: summaries.filter((s) => s.owner === owner) }));422+ // The index's panels; a private build has no index.423+ const lately = priv ? { items: [], active: {} } : recent(cfg, summaries);424+ const profiles = owners.filter((o) => o.repos.length).map((o) => ({ owner: o.owner, repos: o.repos.length, active: lately.active[o.owner] ?? '', colour: colour(o.owner, env) }));425+ const topics = filters.shelves(summaries).map((sh) => {426+ const who = [...new Set(sh.repos.map((r) => r.owner))];427+ return { id: sh.id, title: sh.title, repos: sh.repos.length, href: who.length === 1 ? `/${who[0]}/#${sh.id}` : `/topics/#${sh.id}` };428+ });388429 // Repo search on the index and owner pages.389430 raw.push({ path: '/repos.json', data: Buffer.from(reposIndex(summaries)) });390431 if (!cfg.only || cfg.only === '-') raw.push(...insightFiles(cfg, 'insights', '/insights/'));391- last = { config: cfg, repos: summaries, owners, pages, raw: raw.filter((r) => r.data || r.file), live, code, skipped, pageCaches };432+ last = { config: cfg, repos: summaries, owners, motd: motd(cfg.state), profiles, topics, recent: lately.items, pages, raw: raw.filter((r) => r.data || r.file), live, code, skipped, pageCaches };392433 return last;393434 }394435
@@ -529,7 +570,7 @@ export const filters = {
529570 return html.ci(status, { long });530571 },531572 date: html.date,532- // The index and owner pages: repos on shelves, newest first. A shelf is573+ // Owner pages, /topics/ and the home page's topics: repos on shelves, newest first. A shelf is533574 // the category the repo's owner gave it, else Jev's topic, else "other".534575 // Fuller shelves come first, "other" last.535576 shelves(repos: RepoSummary[]): { id: string; title: string; repos: RepoSummary[] }[] {
A lib/motd.ts +49 -0
@@ -0,0 +1,49 @@
1+// The motd: the quiet line under the header on the home page, which an2+// admin sets (bin/forge motd). It is kept in .forge/motd: no file is the3+// default, an empty one no line at all.4+//5+// node lib/motd.ts default the default line6+// node lib/motd.ts check <text> the text as stored (trimmed), or why not7+import fs from 'node:fs';8+import path from 'node:path';9+import { fileURLToPath } from 'node:url';10+11+export const MOTD_DEFAULT = 'a personal forge on plain git';12+export const MOTD_MAX = 72;13+14+// What the index shows: null for no line.15+export function motd(state: string): string | null {16+ let text: string;17+ try {18+ text = fs.readFileSync(path.join(state, 'motd'), 'utf8');19+ } catch {20+ return MOTD_DEFAULT;21+ }22+ return text.split('\n')[0].trim() || null;23+}24+25+// Plain text on one line, counted in characters; the page escapes it.26+export function check(text: string): { text: string } | { error: string } {27+ const s = text.trim();28+ const n = [...s].length;29+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(s)) return { error: 'one line of plain text, without control characters' };30+ if (!n) return { error: 'empty; motd --clear shows no line' };31+ if (n > MOTD_MAX) return { error: `${n} characters; it can have ${MOTD_MAX} at most` };32+ return { text: s };33+}34+35+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {36+ const [cmd, text = ''] = process.argv.slice(2);37+ if (cmd === 'default') console.log(MOTD_DEFAULT);38+ else if (cmd === 'check') {39+ const r = check(text);40+ if ('error' in r) {41+ console.error(`forge: motd: ${r.error}`);42+ process.exit(1);43+ }44+ console.log(r.text);45+ } else {46+ console.error('usage: node lib/motd.ts default | check <text>');47+ process.exit(2);48+ }49+}
A lib/recent.ts +175 -0
@@ -0,0 +1,175 @@
1+// What happened lately on the forge's public repos, for the index's recent2+// list and its profiles: landed reviews, pushes to a default branch,3+// releases and site deploys, newest first.4+//5+// Pushes, releases and site deploys come from the events log6+// (.forge/events.jsonl): post-receive writes a line for each push with the7+// refs it moved, and the default branch's new tip as head. Landed reviews8+// come from the review logs, which last: a review whose branch was deleted9+// with its tip in the default branch, at the time it was deleted. A push10+// whose head is a review's tip is that landing, and shows as landed. The11+// events log is cut to its last 1000 lines and left out of backups, so12+// after a restore only landed reviews show until the next pushes.13+//14+// Private repos are never read: the list is made from the public repos the15+// build was given, and their events, which carry no "private" mark.16+import fs from 'node:fs';17+import path from 'node:path';18+import { isAncestor, tryRun } from './git.ts';19+import { readReviewLogs } from './reviews.ts';20+import type { Config, RepoSummary } from './types.ts';21+22+export type RecentKind = 'landed' | 'pushed' | 'release' | 'site';23+24+export interface RecentItem {25+ kind: RecentKind;26+ // ISO time, for <time datetime>.27+ at: string;28+ repo: { slug: string; name: string; owner: string };29+ href: string;30+ // The review's #id or the tag, before the text (a release's is its tag only).31+ ref: string;32+ text: string;33+}34+35+// When each owner last did something public: their newest event, landed36+// review or commit. ISO, '' for never.37+export type Active = Record<string, string>;38+39+interface Candidate {40+ kind: RecentKind;41+ t: number;42+ repo: RepoSummary;43+ // pushed: the new head; landed: the review's tip; release, site: the name.44+ key: string;45+ // landed: the review.46+ id?: number;47+ title?: string;48+ base?: string | null;49+}50+51+interface Event {52+ t?: number;53+ repo?: string;54+ type?: string;55+ refs?: string;56+ head?: string;57+ private?: boolean;58+}59+60+function events(state: string): Event[] {61+ let text = '';62+ try {63+ text = fs.readFileSync(path.join(state, 'events.jsonl'), 'utf8');64+ } catch {65+ return [];66+ }67+ const out: Event[] = [];68+ for (const line of text.split('\n')) {69+ if (!line.includes('"type":"push"')) continue;70+ try {71+ const e = JSON.parse(line);72+ if (e && e.type === 'push' && !e.private && typeof e.repo === 'string' && typeof e.t === 'number') out.push(e);73+ } catch {74+ // A line cut short by a crash.75+ }76+ }77+ return out;78+}79+80+const iso = (ms: number): string => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');81+82+export function recent(cfg: Config, repos: RepoSummary[], n = 8): { items: RecentItem[]; active: Active } {83+ const bySlug = new Map(repos.map((r) => [r.slug, r]));84+ const latest: Record<string, number> = {};85+ const seen = (owner: string, t: number): void => {86+ if (t > (latest[owner] ?? 0)) latest[owner] = t;87+ };88+ for (const r of repos) if (r.lastCommit) seen(r.owner, Date.parse(r.lastCommit));89+90+ const list: Candidate[] = [];91+ for (const e of events(cfg.state)) {92+ const repo = bySlug.get(e.repo!);93+ if (!repo) continue;94+ seen(repo.owner, e.t!);95+ const named = new Set<string>();96+ for (const ref of (e.refs ?? '').split(/\s+/).filter(Boolean)) {97+ if (ref === `refs/heads/${repo.defaultBranch}`) {98+ if (e.head && /^[0-9a-f]{40,64}$/.test(e.head)) list.push({ kind: 'pushed', t: e.t!, repo, key: e.head });99+ } else {100+ const m = /^refs\/(tags|releases|sites)\/(.+)$/.exec(ref);101+ if (!m) continue;102+ const kind = m[1] === 'sites' ? 'site' : 'release';103+ // A tag and its release in one push are one release.104+ if (named.has(kind + ' ' + m[2])) continue;105+ named.add(kind + ' ' + m[2]);106+ list.push({ kind, t: e.t!, repo, key: m[2] });107+ }108+ }109+ }110+111+ // Every review's revisions, by repo: a push of one to the default branch landed it.112+ const revs = new Map<string, Set<string>>();113+ for (const repo of repos) {114+ const shas = new Set<string>();115+ for (const r of readReviewLogs(repo.dir, false)) {116+ for (const v of r.revs) shas.add(v.sha);117+ const tip = r.revs.at(-1);118+ if (!r.deleted || !tip) continue;119+ seen(repo.owner, r.deleted.ts * 1000);120+ list.push({ kind: 'landed', t: r.deleted.ts * 1000, repo, key: tip.sha, id: r.id, title: r.opts.title, base: tip.base });121+ }122+ revs.set(repo.slug, shas);123+ }124+125+ // The refs each repo has now: a deleted tag or site isn't news.126+ const refs = new Map<string, Set<string>>();127+ const refsOf = (repo: RepoSummary): Set<string> => {128+ let s = refs.get(repo.slug);129+ if (!s) {130+ s = new Set((tryRun(repo.dir, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/releases', 'refs/sites']) ?? '').split('\n').filter(Boolean));131+ refs.set(repo.slug, s);132+ }133+ return s;134+ };135+ const subject = (repo: RepoSummary, rev: string): string | null => tryRun(repo.dir, ['log', '-1', '--format=%s', rev, '--'])?.trim() ?? null;136+137+ // Newest first; checked one by one (git), until there are n.138+ list.sort((a, b) => b.t - a.t || Number(b.kind === 'landed') - Number(a.kind === 'landed'));139+ const items: RecentItem[] = [];140+ const done = new Set<string>();141+ for (const c of list) {142+ if (items.length >= n) break;143+ const { repo } = c;144+ const once = `${c.kind} ${repo.slug} ${c.key}`;145+ if (done.has(once)) continue;146+ let href = '', ref = '', text = '';147+ if (c.kind === 'pushed') {148+ if (revs.get(repo.slug)?.has(c.key)) continue;149+ const s = subject(repo, c.key);150+ if (s == null) continue;151+ href = repo.urls.commit(c.key).href;152+ text = s;153+ } else if (c.kind === 'landed') {154+ if (!isAncestor(repo.dir, c.key, `refs/heads/${repo.defaultBranch}`)) continue;155+ // Untitled, a review is named for its first commit (as lib/repo.ts names it).156+ const first = c.title ? null : tryRun(repo.dir, ['log', '--reverse', '--format=%s', c.base ? `${c.base}..${c.key}` : c.key, '--'])?.split('\n')[0];157+ href = repo.urls.review(c.id!).href;158+ ref = `#${c.id}`;159+ text = c.title || first || '';160+ } else if (c.kind === 'release') {161+ const have = refsOf(repo);162+ if (!have.has(`refs/tags/${c.key}`) && !have.has(`refs/releases/${c.key}`)) continue;163+ href = `${repo.urls.releases.href}#${encodeURIComponent(c.key)}`;164+ ref = c.key;165+ } else {166+ if (!refsOf(repo).has(`refs/sites/${c.key}`)) continue;167+ const sites = repo.sites ?? [];168+ href = sites.find((s) => s.name === c.key)?.url ?? repo.urls.site(c.key);169+ text = sites.length > 1 ? `deployed its site ${c.key}` : 'deployed its site';170+ }171+ done.add(once);172+ items.push({ kind: c.kind, at: iso(c.t), repo: { slug: repo.slug, name: repo.name, owner: repo.owner }, href, ref, text });173+ }174+ return { items, active: Object.fromEntries(Object.entries(latest).map(([o, t]) => [o, iso(t)])) };175+}
M lib/render-page.ts +3 -2
M lib/reviews.ts +3 -2
M site/_includes/macros.njk +1 -1
M site/assets/style.css +7 -0
M site/assets/ui.ts +5 -1
M site/index.njk +93 -2
@@ -1,10 +1,101 @@
11 ---22 permalink: /index.html33 ---4+{#- The home page: the motd an admin sets (bin/forge motd), then who is5+ here (profiles), the shelves across them (topics) and what happened6+ lately on public repos (recent, lib/recent.ts). Every build redoes it,7+ so it is as fresh as the last push; ui.ts keeps its times relative, and8+ live.ts swaps it in when a build finishes. Each owner's page has their9+ repos on shelves. -#}410 {% extends "base.njk" %}511 {% import "macros.njk" as m with context %}612 {% import "ui.njk" as ui %}713 {% block content %}8-{{ m.shelves(forge.repos, forge.owners.length > 1) }}9-<p class="desc"><a href="/insights/">Insights</a>: builds, pushes, reviews and CI across these repos, over time.</p>14+{% css %}15+main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space-3) 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); color: var(--muted); }16+/* Two columns: profiles and topics, then what happened; on phones one,17+ recent after profiles. */18+.home { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); grid-template-areas: "profiles recent" "topics recent"; grid-template-rows: auto 1fr; gap: var(--space-4); align-items: start; }19+.home > .panel { margin: 0; }20+.home .profiles { grid-area: profiles; }21+.home .topics { grid-area: topics; }22+.home .recent { grid-area: recent; }23+@media (max-width: 720px) {24+ .home { grid-template-columns: minmax(0, 1fr); grid-template-areas: "profiles" "recent" "topics"; grid-template-rows: none; }25+}26+.home .panel-head .end a { font-weight: var(--fw-medium); }27+/* A profile: a letter on a colour, the name, how many repos and when. */28+.profile { position: relative; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); }29+.profile:hover, .profile:focus-within { background: var(--hover); }30+.avatar { display: grid; place-items: center; flex: none; width: 36px; height: 36px; font: var(--fw-strong) var(--text-lg)/1 var(--mono); color: var(--on-accent); background: var(--avatar-1); }31+.avatar.accent { background: var(--accent); }32+.avatar.avatar-2 { background: var(--avatar-2); }33+.avatar.avatar-3 { background: var(--avatar-3); }34+.avatar.avatar-4 { background: var(--avatar-4); }35+.avatar.avatar-5 { background: var(--avatar-5); }36+.profile-text { display: flex; flex-direction: column; min-width: 0; }37+.profile-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-prose); font-weight: var(--fw-strong); color: var(--fg); }38+.profile-name:hover { color: var(--fg); }39+.profile-name::after { content: ''; position: absolute; inset: 0; }40+.profile-meta { font-size: var(--text-sm); color: var(--muted); }41+.profile-meta time { font: inherit; }42+/* Topics: tiles with hairlines between them; the panel's edge is the rule43+ round them, so the last column's and row's own rules are cut off. */44+.topics { overflow: hidden; }45+.topic-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 calc(-1 * var(--hairline)) calc(-1 * var(--hairline)) 0; padding: 0; list-style: none; }46+.topic-grid > li { position: relative; border-right: var(--hairline) solid var(--border); border-bottom: var(--hairline) solid var(--border); }47+.topic-grid > li:hover, .topic-grid > li:focus-within { background: var(--hover); }48+.topic-grid a { display: flex; flex-direction: column; padding: var(--space-2) var(--space-3); color: var(--fg); font-weight: var(--fw-medium); overflow-wrap: anywhere; }49+.topic-grid a:hover { color: var(--fg); }50+.topic-grid .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }51+@media (max-width: 720px) {52+ .topic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }53+}54+/* What happened: its kind, the repo and what, when. */55+.ev { position: relative; display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; column-gap: var(--space-3); align-items: baseline; padding: var(--space-2) var(--space-3); }56+.ev:hover, .ev:focus-within { background: var(--hover); }57+.ev-kind { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }58+.ev-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }59+.ev-what:hover { color: var(--fg); }60+.ev-what::after { content: ''; position: absolute; inset: 0; }61+.ev-what b { font-weight: var(--fw-strong); }62+.ev-what .tag { font-family: var(--mono); font-size: var(--text-inline-mono); color: var(--link); }63+.ev time { font-size: var(--text-xs); color: var(--faint); white-space: nowrap; }64+{% endcss %}65+{% if forge.motd %}<p class="motd">{{ forge.motd }}</p>66+{% endif %}67+<div class="home">68+{% set head %}<h2>profiles</h2><span class="n">{{ forge.profiles.length }}</span>{% endset %}69+{% call ui.panel(head, 'profiles') %}70+{% if forge.profiles.length %}71+<ul class="rows">72+{% for pr in forge.profiles %}73+<li class="profile"><span class="avatar {{ pr.colour }}" aria-hidden="true">{{ pr.owner | first }}</span><span class="profile-text"><a class="profile-name" href="/{{ pr.owner }}/">{{ pr.owner }}</a><span class="profile-meta">{{ pr.repos | plural("repo") }}{% if pr.active %}, active {{ pr.active | date }}{% endif %}</span></span></li>74+{% endfor %}75+</ul>76+{% else %}<p class="empty">No public repos here yet.</p>77+{% endif %}78+{% endcall %}79+{% set head %}<h2>recent</h2><span class="end"><a href="/insights/">insights</a></span>{% endset %}80+{% call ui.panel(head, 'recent') %}81+{% if forge.recent.length %}82+<ul class="rows">83+{% for e in forge.recent %}84+<li class="ev"><span class="ev-kind">{{ e.kind }}</span><a class="ev-what" href="{{ e.href }}"><b>{{ e.repo.name }}</b> {% if e.kind == 'release' %}<span class="tag">{{ e.ref }}</span>{% else %}{{ (e.ref + ' ') if e.ref }}{{ e.text }}{% endif %}</a>{{ e.at | date }}</li>85+{% endfor %}86+</ul>87+{% else %}<p class="empty">Nothing has happened here yet.</p>88+{% endif %}89+{% endcall %}90+{% if forge.topics.length %}91+{% set head %}<h2>topics</h2>{% endset %}92+{% call ui.panel(head, 'topics') %}93+<ul class="topic-grid">94+{% for t in forge.topics %}95+<li><a href="{{ t.href }}">{{ t.title }}<span class="n">{{ t.repos | plural('repo') }}</span></a></li>96+{% endfor %}97+</ul>98+{% endcall %}99+{% endif %}100+</div>10101 {% endblock %}
A site/topics.njk +16 -0
A test/scenarios/home-profiles.sh +35 -0
@@ -0,0 +1,35 @@
1+# The home page has a profile for each owner with public repos: their2+# letter, their name linking to their page, how many public repos they3+# have and when they were last active. No shelves of repos: those are on4+# each owner's page. The header says the forge's name.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+user bob9+for r in dami/chess dami/go ana/notes; do10+ repo "$r"11+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"12+ tick 360013+ at "$T/w/${r#*/}" commit "first"14+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main15+done16+repo --private dami/secret17+repo --private bob/hidden18+export FORGE_STRICT=119+build20+i=$FORGE_WWW/index.html21+has "$i" '<div class="title">22+<b>forge</b></div>'23+has "$i" '<section class="panel profiles"><header class="panel-head"><h2>profiles</h2><span class="n">2</span></header>'24+has "$i" '<a class="profile-name" href="/ana/">ana</a><span class="profile-meta">1 repo, active <time datetime="2026-09-01T03:01:00Z">'25+has "$i" '<a class="profile-name" href="/dami/">dami</a><span class="profile-meta">2 repos, active <time datetime="2026-09-01T02:01:00Z">'26+has "$i" '<span class="avatar avatar-[1-5]" aria-hidden="true">d</span>'27+# bob has only a private repo: no profile.28+lacks "$i" 'bob'29+lacks "$i" 'data-shelves|class="card'30+lacks "$i" 'Insights</a>:'31+# The forge's owner has the accent.32+FORGE_OWNER=dami build33+has "$i" '<span class="avatar accent" aria-hidden="true">d</span>'34+# The owner page keeps its shelves.35+has "$FORGE_WWW/dami/index.html" 'data-shelves'
A test/scenarios/home-recent.sh +58 -0
@@ -0,0 +1,58 @@
1+# The home page's recent list: landed reviews, pushes to a default branch,2+# releases and site deploys of public repos, newest first, each linking to3+# what it names, with its time for ui.ts to say relative. A landing's push4+# shows once, as landed. Nothing of a private repo is in it.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+repo dami/greet9+repo --private dami/secret10+clone dami dami/greet "$T/w/d"11+at "$T/w/d" commit "first"12+push_as dami "$T/w/d" origin main13+first=$(git -C "$T/w/d" rev-parse HEAD)14+tick15+clone ana dami/greet "$T/w/a"16+at "$T/w/a" commit "ana's change"17+push_as ana "$T/w/a" -o 'title=Say it louder' origin HEAD:ana/change18+tick19+ok ssh_as dami land dami/greet 120+tick21+at "$T/w/d" git pull -q origin main22+at "$T/w/d" git tag v123+push_as dami "$T/w/d" origin v124+tick25+push_as dami "$T/w/d" origin "$(site_commit "$T/w/d" docs):refs/sites/docs"26+tick27+at "$T/w/d" commit "polish <b>the</b> greeting"28+push_as dami "$T/w/d" origin main29+last=$(git -C "$T/w/d" rev-parse HEAD)30+# Newest of all, but private.31+tick32+clone dami dami/secret "$T/w/s"33+at "$T/w/s" commit "hidden thing"34+at "$T/w/s" git tag v935+push_as dami "$T/w/s" origin main v9 "$(site_commit "$T/w/s" docs):refs/sites/docs"36+has "$FORGE_ROOT/.forge/events.jsonl" "\"head\":\"$last\",\"refs\":\" refs/heads/main\"}$"37+38+export FORGE_STRICT=139+build40+i=$FORGE_WWW/index.html41+has "$i" '<section class="panel recent"><header class="panel-head"><h2>recent</h2><span class="end"><a href="/insights/">insights</a></span></header>'42+eq "$(grep -o 'class="ev-kind">[a-z]*' "$i" | sed 's/.*>//' | tr '\n' ' ')" "pushed site release landed pushed "43+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$last/\"><b>greet</b> polish &lt;b&gt;the&lt;/b&gt; greeting</a><time datetime=\"2026-09-01T00:05:00Z\">"44+has "$i" '<span class="ev-kind">site</span><a class="ev-what" href="/sites/dami/greet/docs/"><b>greet</b> deployed its site</a><time datetime="2026-09-01T00:04:00Z">'45+has "$i" '<span class="ev-kind">release</span><a class="ev-what" href="/dami/greet/releases/#v1"><b>greet</b> <span class="tag">v1</span></a><time datetime="2026-09-01T00:03:00Z">'46+has "$i" '<span class="ev-kind">landed</span><a class="ev-what" href="/dami/greet/reviews/1/"><b>greet</b> #1 Say it louder</a><time datetime="2026-09-01T00:02:00Z">'47+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$first/\"><b>greet</b> first</a>"48+lacks "$i" 'secret|hidden thing|v9'49+# The links go somewhere.50+exists "$FORGE_WWW/dami/greet/commit/$last/index.html"51+exists "$FORGE_WWW/dami/greet/reviews/1/index.html"52+has "$FORGE_WWW/dami/greet/releases/index.html" 'id="v1"'53+exists "$FORGE_WWW/sites/dami/greet/docs/index.html"54+55+# Made private, the repo leaves the list.56+ok ssh_as dami private dami/greet57+build58+has "$i" '<p class="empty">Nothing has happened here yet.</p>'
A test/scenarios/home-topics.sh +37 -0
@@ -0,0 +1,37 @@
1+# The home page's topics are the shelves across the public repos, each2+# with its count. One owner's shelf links to it on their page, where ui.ts3+# opens the tab the fragment names; a shelf several owners share links to4+# it on /topics/, every public repo by shelf, each naming its owner.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+for r in dami/chess dami/go dami/hammer ana/cards ana/notes; do9+ repo "$r"10+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"11+ at "$T/w/${r#*/}" commit "first"12+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main13+done14+repo --private ana/secret15+for r in dami/chess dami/go ana/cards ana/secret; do printf 'games\n' >"$FORGE_ROOT/$r.git/category"; done16+printf 'tools\n' >"$FORGE_ROOT/dami/hammer.git/category"17+export FORGE_STRICT=118+build19+i=$FORGE_WWW/index.html20+has "$i" '<li><a href="/topics/#games">games<span class="n">3 repos</span></a></li>'21+has "$i" '<li><a href="/dami/#tools">tools<span class="n">1 repo</span></a></li>'22+has "$i" '<li><a href="/ana/#other">other<span class="n">1 repo</span></a></li>'23+# Where they go.24+has "$FORGE_WWW/dami/index.html" '<a href="#tools" data-shelf="tools">tools<span class="n">1</span></a>'25+has "$FORGE_WWW/dami/index.html" 'data-shelf="tools" id="tools"'26+t=$FORGE_WWW/topics/index.html27+has "$t" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">3</span></header>'28+has "$t" '<a href="#games" data-shelf="games">games<span class="n">3</span></a>'29+has "$t" '<span class="desc">ana / </span>cards</a>'30+lacks "$t" 'secret'31+# ui.ts opens the shelf a link names.32+has site/assets/ui.ts 'location.hash'33+# A single repo's build redoes /topics/ too.34+printf 'tools\n' >"$FORGE_ROOT/ana/notes.git/category"35+build ana/notes36+has "$t" 'data-shelf="tools" id="tools"><header class="panel-head"><h2>tools</h2><span class="n">2</span>'37+has "$i" '<li><a href="/topics/#tools">tools<span class="n">2 repos</span></a></li>'
M test/scenarios/insights-pages.sh +1 -1
M test/scenarios/panels-shelves.sh +2 -2
M test/scenarios/shelves-site-toggle.sh +2 -2
M test/scenarios/shelves.sh +2 -2